From 7d4be685df226373baed9f0bae4386eddeb49079 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 13:49:59 +0200 Subject: [PATCH 001/109] feat: migrate report actions to chronological LegendList --- patches/react-native/details.md | 3 +- ...act-native+0.86.0+002+fixMVCPAndroid.patch | 12 +- .../CellRendererComponent.tsx | 30 -- .../FlashList/InvertedFlashList/index.tsx | 28 -- src/components/FlashList/index.tsx | 2 +- src/components/FlashList/types.ts | 8 +- .../FlatList/FlatList/index.ios.tsx | 2 +- src/components/FlatList/FlatList/index.tsx | 2 +- .../KeyboardDismissibleFlatList/index.tsx | 2 +- .../MoneyRequestReportActionsList.tsx | 6 +- .../LocalPDFReceiptPreview/index.tsx | 9 +- .../ReceiptPDFOverlay/index.tsx | 6 +- .../useEmitComposerScrollEvents/index.ts | 11 +- src/hooks/useReportActionsListModel.ts | 4 + src/hooks/useReportActionsScroll.ts | 159 ++------ .../useReportScrollManager/index.native.ts | 7 +- src/hooks/useReportScrollManager/index.ts | 7 +- src/pages/inbox/ActionListContext.tsx | 14 +- src/pages/inbox/ActionListTypes.ts | 31 ++ .../report/MoneyReportContentCreated.tsx | 1 + .../ReportActionCompose/useEditMessage.ts | 2 +- .../inbox/report/ReportActionIndexContext.tsx | 26 +- src/pages/inbox/report/ReportActionItem.tsx | 19 +- .../report/ReportActionItemContentCreated.tsx | 1 + .../report/ReportActionItemMessageEdit.tsx | 4 +- src/pages/inbox/report/ReportActionsList.tsx | 242 +++++++++--- .../actionContents/ActionContentRouter.tsx | 2 +- .../actionContents/ChatMessageContent.tsx | 1 + .../report/shouldFollowActionBadgeTarget.ts | 12 +- .../report/useFollowActionBadgeTarget.ts | 4 +- .../useReportActionsNewActionLiveTail.ts | 20 +- .../useReportUnreadMessageScrollTracking.ts | 4 +- tests/ui/PaginationTest.tsx | 17 +- tests/ui/ReportActionsListTest.tsx | 353 ++++++++++++++++-- tests/unit/ReportActionsListThresholdTest.tsx | 64 ++-- tests/unit/hooks/useEditMessage.test.ts | 16 +- .../unit/shouldFollowActionBadgeTargetTest.ts | 8 +- .../useReportActionsNewActionLiveTailTest.ts | 25 +- tests/unit/useReportActionsScrollTest.tsx | 186 ++------- ...seReportUnreadMessageScrollTrackingTest.ts | 48 +++ 40 files changed, 849 insertions(+), 549 deletions(-) delete mode 100644 src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx delete mode 100644 src/components/FlashList/InvertedFlashList/index.tsx create mode 100644 src/pages/inbox/ActionListTypes.ts 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 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 b343f95b8be1..000000000000 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ /dev/null @@ -1,28 +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 & { - data: T[]; - 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/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/FlashList/types.ts b/src/components/FlashList/types.ts index cf7718d3d148..8563ec9959a4 100644 --- a/src/components/FlashList/types.ts +++ b/src/components/FlashList/types.ts @@ -1,7 +1,3 @@ -import type {RefObject} from 'react'; -import type {FlatList} from 'react-native'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; -/** Ref to the underlying list instance attached via `ref={}`. */ -type FlatListRefType = RefObject | null> | null; - -export default FlatListRefType; +export default ActionListRefType; 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/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 1b8f09edaa03..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 ( - + (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/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; } diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index b3e438fd3adb..67e39bdd688d 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -146,7 +146,11 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + hasOlderActions, hasNewerActions, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + oldestReportActionID: currentReportOldestActionID, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index b899c31b1312..3f5ce23335be 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -34,8 +34,6 @@ import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; -import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; -import useWindowDimensions from './useWindowDimensions'; type UseReportActionsScrollParams = { /** The Concierge chat report */ @@ -55,15 +53,13 @@ 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 */ 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; @@ -79,9 +75,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; @@ -112,28 +105,16 @@ type UseReportActionsScrollResult = { 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; - /** Whether the list should focus to the visual top on mount */ - shouldFocusToTopOnMount: boolean; - - /** The initial scroll target key for the list */ - initialScrollKey: string | undefined; - - /** maintainVisibleContentPosition config for the inverted list */ - maintainVisibleContentPosition: {disabled: boolean; autoscrollToBottomThreshold?: number; animateAutoScrollToBottom?: boolean}; - /** The index the list should scroll to on mount (undefined to keep default position) */ initialScrollIndex: number | undefined; /** Positioning params (viewPosition/viewOffset) paired with initialScrollIndex */ initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - /** onLoad handler that disables autoscroll-to-top once the initial render settles */ + /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; }; @@ -146,13 +127,11 @@ function useReportActionsScroll({ sortedVisibleReportActions, renderedVisibleReportActions, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, unreadMarkerReportActionIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination, treatAsNoPaginationAnchor, @@ -160,7 +139,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; @@ -204,18 +182,8 @@ function useReportActionsScroll({ } const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey && !shouldScrollToLatestOnOpen; - 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, @@ -223,7 +191,7 @@ function useReportActionsScroll({ onUnreadActionVisible: completeSkippedMarkAsRead, hasNewerActions, unreadMarkerReportActionIndex, - isInverted: true, + isInverted: false, shouldDisablePillTracking, onTrackScrolling: (event: NativeSyntheticEvent) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; @@ -247,7 +215,7 @@ function useReportActionsScroll({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -256,57 +224,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]); - - 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(); - }, []); + reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(false); + completeLiveTailPruneAfterScrollToBottom(); + }, [isScrollToBottomEnabled, reportScrollManager, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom]); // Clear the shouldScrollToLatest route param once the mount scroll above has consumed it, so a later remount of // this report doesn't pull the user down again. MoneyRequestReportActionsList clears it the same way for the @@ -393,64 +321,33 @@ 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 = () => { - if (!isScrollToBottomEnabled) { - return; - } - reportScrollManager.scrollToBottom(); - setIsScrollToBottomEnabled(false); - 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: 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; } return { @@ -460,11 +357,7 @@ function useReportActionsScroll({ isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollKey, - maintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, onLoad, 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/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index a2764b32c88b..f620b9e53724 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,10 +1,10 @@ -import type FlatListRefType from '@components/FlashList/types'; - import type {ReactNode, RefObject} from 'react'; -import type {FlatList} from 'react-native'; import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; +import type ActionListRefType from './ActionListTypes'; +import type {ActionListRef} from './ActionListTypes'; + type ActionListContextType = { scrollOffsetRef: RefObject; @@ -12,10 +12,10 @@ type ActionListContextType = { getScrollOffset: () => number; /** Each list publishes its locally-owned ref on mount; pass `null` to clear on unmount. */ - registerListRef: (ref: FlatListRefType) => void; + registerListRef: (ref: ActionListRefType) => void; /** Reads the currently registered list ref. Call from handlers only, never during render. */ - getListRef: () => FlatListRefType; + getListRef: () => ActionListRefType; }; const ActionListContext = createContext({ @@ -35,7 +35,7 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); - const listRef = useRef(null); + const listRef = useRef(null); useLayoutEffect(() => { registerListRef(listRef); @@ -49,7 +49,7 @@ function useActionListRef() { function ActionListContextProvider({children}: {children: ReactNode}) { // Each list owns its own ref locally and publishes it here on mount; only the register/get // callbacks live in context, so attaching `ref={}` stays local to each list. - const listRefHolder = useRef(null); + const listRefHolder = useRef(null); const scrollOffsetRef = useRef(0); const value: ActionListContextType = { diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts new file mode 100644 index 000000000000..d429168c46cb --- /dev/null +++ b/src/pages/inbox/ActionListTypes.ts @@ -0,0 +1,31 @@ +import type {RefObject} from 'react'; + +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 ActionListRefType = RefObject | null; + +export default ActionListRefType; +export type {ActionListRef}; diff --git a/src/pages/inbox/report/MoneyReportContentCreated.tsx b/src/pages/inbox/report/MoneyReportContentCreated.tsx index 46096456685d..efd9f93c761b 100644 --- a/src/pages/inbox/report/MoneyReportContentCreated.tsx +++ b/src/pages/inbox/report/MoneyReportContentCreated.tsx @@ -75,6 +75,7 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre (0); +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 7011ba259e63..37c041d5b16e 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, {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'; @@ -203,16 +205,19 @@ 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(() => { + 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(); @@ -356,7 +361,7 @@ function ReportActionItem({ return; } setIsHidden(false); - }, [latestDecision, action]); + }, [latestDecision, action, setIsHidden]); const toggleContextMenuFromActiveReportAction = () => { setIsContextMenuActive(isActiveReportAction(action.reportActionID)); diff --git a/src/pages/inbox/report/ReportActionItemContentCreated.tsx b/src/pages/inbox/report/ReportActionItemContentCreated.tsx index c284acd7a82a..d8fe6333c1b3 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -96,6 +96,7 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf (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. - 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); @@ -172,6 +236,24 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {getScrollOffset} = useActionListContext(); const listRef = useActionListRef(); + const legendListRef = useRef(null); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + getNativeScrollRef: () => legendListRef.current?.getNativeScrollRef(), + 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(); @@ -181,7 +263,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ + const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, sortedVisibleReportActions, sortedReportActions, @@ -234,10 +316,25 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct 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 = reportActionsToRender.toReversed(); + 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) { @@ -255,9 +352,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct 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, @@ -266,12 +364,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, initialScrollIndex, initialScrollIndexParams, - maintainVisibleContentPosition, onLoad, } = useReportActionsScroll({ reportID, @@ -280,33 +375,60 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct transactionThreadReport, parentReportAction, sortedVisibleReportActions, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, - unreadMarkerReportActionIndex, + unreadMarkerReportActionIndex: unreadMarkerListIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination: sortedAllReportActions ?? [], treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor, }); - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); + + const handleListLoad = () => { + onLoad(); + setLoadedInitialViewportListID(listID); }; - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { + 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; + } + + const bottomRelativeEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: distanceFromBottom}, + }, + }; + + trackVerticalScrolling(bottomRelativeEvent); + setHasScrolledOverThreshold(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + emitComposerScrollEvents(); + }; + const loadNewerChatsAfterTransitions = () => { if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); @@ -327,7 +449,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct reportID, actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, actionBadgeTargetIndex, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, scrollToActionBadgeTarget, }); @@ -355,11 +477,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct 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; + const reportActionIndex = reportActionsToRender.length - index - 1; return ( - + 1} + shouldDisplayReplyDivider={reportActionsToRender.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} @@ -456,46 +579,49 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct report={report} isReportArchived={isReportArchived} > - { - recordTimeToMeasureItemLayout(event); - flushPendingScrollToBottom(); - }} + onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} key={listID} - overrideProps={{ - isInvertedVirtualizedList: true, - contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, - }} - getItemType={(item) => item.actionName} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} - maintainVisibleContentPosition={maintainVisibleContentPosition} - onLoad={onLoad} - onContentSizeChange={() => { - trackVerticalScrolling(undefined); - }} + getItemType={getItemType} + initialScrollAtEnd={initialScrollIndex === undefined} + initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} + alignItemsAtEnd={!shouldBeAlignedToTop} + // Only follow the real latest page. Older/linked windows must retain their visible anchor. + maintainScrollAtEnd={!hasNewerActions && {animated: false}} + // Leave the end-follow region as soon as the user starts reading older messages. + maintainScrollAtEndThreshold={0.01} + maintainVisibleContentPosition + onLoad={handleListLoad} + onContentSizeChange={() => trackVerticalScrolling(undefined)} /> + {shouldShowInitialViewportSkeleton && ( + + + + )} ); diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 0e8f9d76f666..5f55dfb63898 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -204,7 +204,7 @@ function ActionContentRouter({ if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { return ( {isEditingInline ? ( = 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 b15df8c3a2ff..67c952452920 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -18,10 +18,10 @@ 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 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 da424fdf5e13..bb150ca6d99a 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -48,7 +48,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; @@ -61,9 +62,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, @@ -80,7 +80,7 @@ function useReportActionsNewActionLiveTail({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -132,14 +132,14 @@ 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) { + setIsFloatingMessageCounterVisible(false); + if (index >= 0 && index < renderedVisibleReportActions.length - 1) { setTimeout(() => { reportScrollManager.scrollToIndex(index); }, 100); } else { - setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); } if (action?.reportActionID) { @@ -147,10 +147,8 @@ function useReportActionsNewActionLiveTail({ } } else { setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(true); } - - setIsScrollToBottomEnabled(true); }, }); }); diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 053d23522914..8f41679a75fd 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -157,11 +157,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 { diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index fd23ffdab5bf..f393af39fb38 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -43,6 +43,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'; @@ -315,7 +316,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(); @@ -339,7 +340,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); @@ -370,8 +371,8 @@ describe('Pagination', () => { jest.requireMock('@react-navigation/native').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(); @@ -393,10 +394,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); @@ -405,10 +406,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 0b1aac4cf61e..4a0b2c90a543 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'; @@ -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 = { @@ -113,10 +115,12 @@ 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(), + jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ + loadOlderChats: mockLoadOlderChats, loadNewerChats: jest.fn(), + currentReportOldestActionID: reportActions.at(-1)?.reportActionID, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -124,11 +128,30 @@ 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 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/FlashList/InvertedFlashList', () => jest.fn(() => null)); +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(({onLoad}: {onLoad?: () => void}) => { + reactModule.useEffect(() => { + mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + } + 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', () => @@ -140,11 +163,9 @@ jest.mock('@hooks/useReportActionsScroll', () => isActionBadgeAboveViewport: false, scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), - flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, - shouldFocusToTopOnMount: false, - initialScrollKey: undefined, - shouldAutoscrollToBottom: false, + initialScrollIndex: undefined, + initialScrollIndexParams: undefined, onLoad: jest.fn(), })), ); @@ -156,18 +177,35 @@ 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 = { + alignItemsAtEnd?: boolean; data?: OnyxTypes.ReportAction[]; + drawDistance?: number; extraData?: unknown; + getItemType?: (item: OnyxTypes.ReportAction) => string; + initialScrollAtEnd?: boolean; + maintainScrollAtEnd?: {animated: boolean} | false; + maintainScrollAtEndThreshold?: number; + maintainVisibleContentPosition?: boolean; + onLoad?: () => void; + recycleItems?: boolean; 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/FlashList/InvertedFlashList'); +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}); @@ -191,6 +229,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(), @@ -233,6 +272,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( @@ -252,6 +304,8 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + mockHasOnceLoadedReportActions = true; + mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -314,7 +368,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'}]; @@ -334,6 +388,248 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + 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(0.01); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + }); + + 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('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; + 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 viewport covered until the hydrated 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.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + 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; + const groupingSpy = jest.spyOn(ReportActionsUtils, 'isConsecutiveActionMadeByPreviousActor'); + 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); + + const snapshotActions = getCapturedVisibleActions(); + const oldestSnapshotAction = snapshotActions?.at(0); + if (!oldestSnapshotAction || !snapshotActions) { + throw new Error('Expected the initial action snapshot to remain visible'); + } + getRenderedReportActionsListItemProps(oldestSnapshotAction); + expect(groupingSpy).toHaveBeenLastCalledWith(snapshotActions.toReversed(), snapshotActions.length - 1, false); + groupingSpy.mockRestore(); + + 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(); + + const listProps = getCapturedListProps(); + + expect(listProps?.drawDistance).toBe(1500); + 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({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + }); + const view = 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?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + act(() => { + 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); + }); + describe('Concierge Draft Context Menu', () => { const conciergeDraftReportAction: OnyxTypes.ReportAction = { reportID: mockReport.reportID, @@ -670,10 +966,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', () => { @@ -737,7 +1033,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(); }); }); @@ -830,7 +1126,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); @@ -848,7 +1144,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); @@ -881,7 +1177,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); @@ -899,7 +1195,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); @@ -945,7 +1241,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); @@ -968,9 +1264,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({ @@ -981,8 +1278,8 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + 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/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index d8ce64ac8449..61c2508361be 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -30,38 +30,52 @@ 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 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 (`!disabled`), 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 data-based maintain-visible-content-position. function isMvcpEnabled() { const config = capturedListProps.maintainVisibleContentPosition; - return config ? !config.disabled : undefined; + return config === true || (typeof config === 'object' && config.data); } -jest.mock('@components/FlashList/InvertedFlashList', () => { +jest.mock('@legendapp/list/react-native', () => { 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 + LegendList: forwardRef((props, ref) => { capturedListProps = props; - mockMvcpHistory.push(props.maintainVisibleContentPosition ? !props.maintainVisibleContentPosition.disabled : undefined); 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 { @@ -120,7 +134,6 @@ async function renderList(initialOffset: number) { beforeEach(async () => { capturedListProps = {}; - mockMvcpHistory = []; setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); await act(async () => { @@ -145,34 +158,31 @@ 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?.({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); + expect(isMvcpEnabled()).toBe(true); }); }); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index aebd3cdc0186..ea9a5a836dde 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -58,9 +58,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', () => { @@ -142,4 +143,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/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', () => { diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 87e435b002a5..cf372cf3122c 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -101,7 +101,7 @@ function buildParams(overrides: Partial = {}): HookParams { hasNewerActions: true, linkedReportActionID: undefined, hasNewestReportAction: false, - sortedVisibleReportActions: [], + renderedVisibleReportActions: [], sortedAllReportActionsForPagination: [], reportActionPages: undefined, setTreatAsNoPaginationAnchor: jest.fn(), @@ -120,6 +120,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 0cf03c9ff5e6..118361255128 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, }), })); @@ -73,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', () => ({ @@ -183,13 +179,11 @@ function buildParams(overrides: Partial = {}): ScrollParams { sortedVisibleReportActions: [makeAction('1')], renderedVisibleReportActions: [makeAction('1')], keyExtractor: (item: ReportAction) => item.reportActionID, - hasScrolledOverThreshold: false, markNewestActionAsRead: mockMarkNewestActionAsRead, completeSkippedMarkAsRead: mockCompleteSkippedMarkAsRead, unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1, hasNewerActions: false, - draftAutoScrollKey: '', actionBadgeTargetIndex: -1, sortedAllReportActionsForPagination: [], treatAsNoPaginationAnchor: false, @@ -221,10 +215,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}); @@ -240,6 +230,9 @@ describe('useReportActionsScroll', () => { mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; mockIsScrollToBottomEnabled = false; + mockSetIsScrollToBottomEnabled.mockImplementation((enabled: boolean) => { + mockIsScrollToBottomEnabled = enabled; + }); mockIsTransactionThread = false; mockIsSentMoneyReportAction = false; mockIsReportPreviewAction = false; @@ -259,9 +252,6 @@ 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(); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -270,8 +260,6 @@ 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); }); it('is aligned to top for a money request report', async () => { @@ -290,22 +278,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 () => { @@ -316,9 +307,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(); }); it('does not focus to top for a single-expense money request report opened from the X Replies link', async () => { @@ -329,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(); }); @@ -340,7 +329,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); }); }); @@ -403,135 +391,48 @@ 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}); }); }); - 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); - }); - }); - - 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); + mockScrollOffsetRef.current = 9999; + rerender(buildParams()); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); }); 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('leaves incoming-message following to LegendList', async () => { + mockScrollOffsetRef.current = 0; - it('does not scroll to bottom on mount when the list focuses to top', async () => { - mockIsTransactionThread = true; + const {rerender} = await renderScroll(); - await renderScroll(); - flushTransitions(); + const actions = [makeAction('2'), makeAction('1')]; + rerender(buildParams({sortedVisibleReportActions: actions, renderedVisibleReportActions: actions.toReversed()})); 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'}; @@ -541,32 +442,15 @@ describe('useReportActionsScroll', () => { expect(mockSetParams).toHaveBeenCalledWith({shouldScrollToLatest: undefined}); }); - it('does not clear the X Replies flag when it was never set', async () => { - mockIsMoneyRequestReport = true; - - await renderScroll(); - - expect(mockSetParams).not.toHaveBeenCalled(); - }); - - it('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { + it('does not schedule a competing scroll when a streamed draft grows', async () => { mockScrollOffsetRef.current = 0; - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); - - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalled(); - }); - - it('does not auto-scroll on a new draft key when scrolled away from the bottom', async () => { - mockScrollOffsetRef.current = 9999; - - 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(); }); diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index ecc1f04d688f..09c0d77e37d8 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -227,6 +227,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', () => { @@ -281,6 +307,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 ba852b260a53ce70660ae2454689dd64f5718329 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 14:34:21 +0200 Subject: [PATCH 002/109] fix: address LegendList report action review --- .../MoneyRequestReportActionsList.tsx | 46 ++++---- .../ReportActionCompose/useEditMessage.ts | 13 ++- .../inbox/report/ReportActionIndexContext.tsx | 5 +- .../report/ReportActionItemMessageEdit.tsx | 4 +- src/pages/inbox/report/ReportActionsList.tsx | 13 ++- .../perf-test/ReportActionsList.perf-test.tsx | 4 +- ...equestReportActionsListRejectModalTest.tsx | 101 ++++++++++++++++-- tests/ui/ReportActionItemMessageEditTest.tsx | 25 ++++- tests/ui/ReportActionsListTest.tsx | 13 +-- tests/unit/hooks/useEditMessage.test.ts | 15 +++ 10 files changed, 192 insertions(+), 47 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 72aacd5b03c7..f9c917933222 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -51,7 +51,7 @@ import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListCon import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; +import ReportActionIndexContext, {ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; @@ -638,27 +638,32 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) // 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}; + const reportActionIndexContextValue = { + index: indexWithinReportActions, + isNewest: indexWithinReportActions === visibleReportActions.length - 1, + }; return ( - - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - shouldHideThreadDividerLine - linkedReportActionID={linkedReportActionID} - isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - /> - + + + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + shouldHideThreadDividerLine + linkedReportActionID={linkedReportActionID} + isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + + ); }, [ @@ -674,6 +679,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) shouldShowHarvestCreatedAction, draftReportActionID, isDraftPendingCompletion, + scrollToBottom, ], ); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index f4657b695142..6552c2cfcbf3 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -23,6 +23,7 @@ type UseEditMessageProps = { originalReportID: string | undefined; reportAction: OnyxTypes.ReportAction | null | undefined; shouldScrollToLastMessage?: boolean; + scrollToLastMessage?: () => void; debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; composerRef: React.RefObject; }; @@ -30,7 +31,15 @@ type UseEditMessageProps = { /** * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. */ -function useEditMessage({reportID, originalReportID, reportAction, shouldScrollToLastMessage = false, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { +function useEditMessage({ + reportID, + originalReportID, + reportAction, + shouldScrollToLastMessage = false, + scrollToLastMessage, + debouncedCommentMaxLengthValidation, + composerRef, +}: UseEditMessageProps) { const reportScrollManager = useReportScrollManager(); const {email} = useCurrentUserPersonalDetails(); @@ -52,7 +61,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.scrollToBottom(); + (scrollToLastMessage ?? reportScrollManager.scrollToBottom)(); } } diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 4cb8f9a04ecd..04a1a0d22bd0 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -19,6 +19,9 @@ type ReportActionPosition = { const ReportActionIndexContext = createContext({index: 0, isNewest: false}); +/** Lets shared list implementations provide their own reliable way to reach the newest action. */ +const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); + /** * Uses LegendList's recycling-aware state in the main report list and behaves like useState in shared, non-recycled lists. */ @@ -29,5 +32,5 @@ function useReportActionItemState(initialState: State | (() => State)): [ return isRecycling ? [...recyclingState] : state; } -export {useReportActionItemState}; +export {ReportActionScrollToNewestContext, useReportActionItemState}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 9933a42fa34f..18642635b577 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -58,7 +58,7 @@ import Suggestions from './ReportActionCompose/Suggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; -import ReportActionIndexContext from './ReportActionIndexContext'; +import ReportActionIndexContext, {ReportActionScrollToNewestContext} from './ReportActionIndexContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; import useDebouncedSaveDraft from './useDebouncedSaveDraft'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; @@ -87,6 +87,7 @@ const DEFAULT_MODAL_VALUE = { function ReportActionItemMessageEdit({action, reportID, originalReportID, policyID, ref}: ReportActionItemMessageEditProps) { const {index, isNewest} = useContext(ReportActionIndexContext); + const scrollToNewestAction = useContext(ReportActionScrollToNewestContext); 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)}`); @@ -259,6 +260,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy originalReportID, reportAction: action, shouldScrollToLastMessage: isNewest, + scrollToLastMessage: scrollToNewestAction, debouncedCommentMaxLengthValidation, composerRef, }); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 51d6101678d5..1d72b790cc49 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -95,14 +95,19 @@ const REPORT_ACTION_COMMENT_SIZE = { EXTRA_LONG: 'extra-long', } as const; +// Character-count boundaries used to bucket comments so LegendList can recycle similarly sized items. +const REPORT_ACTION_COMMENT_SHORT_MAX_LENGTH = 80; +const REPORT_ACTION_COMMENT_MEDIUM_MAX_LENGTH = 320; +const REPORT_ACTION_COMMENT_LONG_MAX_LENGTH = 1200; + function getReportActionCommentSize(messageLength: number): string { - if (messageLength <= 80) { + if (messageLength <= REPORT_ACTION_COMMENT_SHORT_MAX_LENGTH) { return REPORT_ACTION_COMMENT_SIZE.SHORT; } - if (messageLength <= 320) { + if (messageLength <= REPORT_ACTION_COMMENT_MEDIUM_MAX_LENGTH) { return REPORT_ACTION_COMMENT_SIZE.MEDIUM; } - if (messageLength <= 1200) { + if (messageLength <= REPORT_ACTION_COMMENT_LONG_MAX_LENGTH) { return REPORT_ACTION_COMMENT_SIZE.LONG; } return REPORT_ACTION_COMMENT_SIZE.EXTRA_LONG; @@ -600,6 +605,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyboardShouldPersistTaps="handled" onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} + onStartReached={loadOlderChatsOnStartReached} + onStartReachedThreshold={PAGINATION_THRESHOLD} onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} key={listID} diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index d39902dc322c..a1ab104f6a2b 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -120,7 +120,9 @@ function ReportActionsListWrapper() { ); } -test('[ReportActionsList] should render ReportActionsList with 500 reportActions stored', async () => { +// LegendList's behavior mock eagerly renders the supplied data, while the previous FlashList test used +// its virtualized implementation. Start a renderer-specific baseline instead of comparing those harnesses. +test('[ReportActionsList] should render LegendList with 500 reportActions stored', async () => { const scenario = async () => { await screen.findByTestId('report-actions-list'); }; diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index da7168b6e9c3..717e79d489d9 100644 --- a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx +++ b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx @@ -11,6 +11,9 @@ import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import type * as InFlightRequests from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; +import {ActionListContextProvider} from '@pages/inbox/ActionListContext'; +import type * as ReportActionIndexContexts from '@pages/inbox/report/ReportActionIndexContext'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -31,6 +34,12 @@ const FAKE_POLICY_ID = 'FAKE_POLICY_001'; const FAKE_ACCOUNT_ID = 15593135; const FAKE_TRANSACTION_ID = 'FAKE_TXN_001'; const FAKE_EMAIL = 'testuser@example.com'; +const MOCK_UNIFIED_LAST_ITEM_INDEX = 37; +const mockScrollToIndex = jest.fn(); +const mockScrollToEnd = jest.fn(); +const mockScrollToOffset = jest.fn(); +let mockReportActionPosition: {index: number; isNewest: boolean} | undefined; +let mockScrollToNewestAction: (() => void) | undefined; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -80,12 +89,38 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionList', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View} = require('react-native'); - return ({listFooterComponent, isLoadingInitialActions}: {listFooterComponent?: React.ReactElement; isLoadingInitialActions: boolean}) => ( - - {isLoadingInitialActions ? : null} - {listFooterComponent} - - ); + const ReactActual = jest.requireActual('react'); + return ({ + listFooterComponent, + isLoadingInitialActions, + listRef, + onLastItemIndexChange, + visibleReportActions, + renderReportAction, + }: { + listFooterComponent?: React.ReactElement; + isLoadingInitialActions: boolean; + listRef: React.Ref<{ + scrollToIndex: typeof mockScrollToIndex; + scrollToEnd: typeof mockScrollToEnd; + scrollToOffset: typeof mockScrollToOffset; + }>; + onLastItemIndexChange?: (index: number) => void; + visibleReportActions: ReportAction[]; + renderReportAction: (reportAction: ReportAction, index: number) => React.ReactElement; + }) => { + ReactActual.useImperativeHandle(listRef, () => ({scrollToIndex: mockScrollToIndex, scrollToEnd: mockScrollToEnd, scrollToOffset: mockScrollToOffset})); + ReactActual.useLayoutEffect(() => onLastItemIndexChange?.(MOCK_UNIFIED_LAST_ITEM_INDEX), [onLastItemIndexChange]); + const newestAction = visibleReportActions.at(-1); + + return ( + + {isLoadingInitialActions ? : null} + {newestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null} + {listFooterComponent} + + ); + }; }); jest.mock('@components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState', () => { @@ -159,7 +194,17 @@ jest.mock('@hooks/useMobileSelectionMode', () => jest.fn(() => true)); jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({shouldUseNarrowLayout: true}))); jest.mock('@hooks/useFilterSelectedTransactions', () => jest.fn()); jest.mock('@hooks/useLoadReportActions', () => jest.fn(() => ({loadOlderChats: jest.fn(), loadNewerChats: jest.fn()}))); -jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => jest.fn(() => null)); +jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => { + const ReactActual = jest.requireActual('react'); + const {default: ReportActionIndexContextActual, ReportActionScrollToNewestContext: ReportActionScrollToNewestContextActual} = + jest.requireActual('@pages/inbox/report/ReportActionIndexContext'); + + return jest.fn(() => { + mockReportActionPosition = ReactActual.useContext(ReportActionIndexContextActual); + mockScrollToNewestAction = ReactActual.useContext(ReportActionScrollToNewestContextActual); + return null; + }); +}); jest.mock('@hooks/useParentReportAction', () => jest.fn(() => undefined)); jest.mock('@navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn(() => false)); @@ -223,9 +268,23 @@ const mockReportAction = createMock { return render( - + @@ -250,6 +309,8 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { beforeEach(async () => { jest.clearAllMocks(); + mockReportActionPosition = undefined; + mockScrollToNewestAction = undefined; jest.spyOn(NativeNavigation, 'useIsFocused').mockReturnValue(true); mockUseIsReportLoadPending.mockReturnValue(false); mockUseNetwork.mockReturnValue({isOffline: false}); @@ -259,6 +320,30 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { }); }); + it('should use the unified list index when the newest action requests a bottom scroll', async () => { + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_REPORT_ID}` as const]: mockReport, + [`${ONYXKEYS.COLLECTION.POLICY}${FAKE_POLICY_ID}` as const]: mockPolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${FAKE_TRANSACTION_ID}` as const]: mockTransaction, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${FAKE_REPORT_ID}` as const]: { + [mockReportAction.reportActionID]: mockReportAction, + [mockCommentReportAction.reportActionID]: mockCommentReportAction, + }, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${FAKE_REPORT_ID}` as const]: {isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, + [ONYXKEYS.SESSION]: {accountID: FAKE_ACCOUNT_ID, email: FAKE_EMAIL} as Session, + }); + }); + + renderComponent(); + await waitForBatchedUpdatesWithAct(); + + expect(mockReportActionPosition).toEqual(expect.objectContaining({isNewest: true})); + act(() => mockScrollToNewestAction?.()); + expect(mockScrollToIndex).toHaveBeenCalledWith({index: MOCK_UNIFIED_LAST_ITEM_INDEX, animated: false, viewPosition: 1}); + expect(mockScrollToEnd).not.toHaveBeenCalled(); + }); + it('should show reject educational modal when reject option is selected and explanation has NOT been dismissed', async () => { await act(async () => { await Onyx.multiSet({ diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 302edd2eb643..af137b294ba1 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -9,6 +9,7 @@ import {editReportComment} from '@libs/actions/Report'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import ReportActionIndexContext, {ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; @@ -86,13 +87,17 @@ function ReportScreenProviders({children}: PropsWithChildren) { return {children}; } -const renderReportActionItemMessageEdit = (props?: Partial) => { +const renderReportActionItemMessageEdit = (props?: Partial, listContext?: {index: number; isNewest: boolean; scrollToNewestAction?: () => void}) => { return render( - + + + + + , ); }; @@ -210,5 +215,15 @@ describe('ReportActionCompose Integration Tests', () => { expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-height'); expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-width'); }); + + it('should use the list-specific scroll after saving the newest message', () => { + const scrollToNewestAction = jest.fn(); + renderReportActionItemMessageEdit(undefined, {index: 9, isNewest: true, scrollToNewestAction}); + + fireEvent.changeText(screen.getByTestId('composer'), 'Edited message'); + fireEvent.press(screen.getByLabelText('common.saveChanges')); + + expect(scrollToNewestAction).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 4a0b2c90a543..35957761cbb8 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -188,6 +188,7 @@ type MockLegendListProps = { maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; onLoad?: () => void; + onStartReachedThreshold?: number; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -549,21 +550,21 @@ describe('ReportActionsList (body)', () => { getItemType?.({ ...comment, reportActionID: 'medium-comment', - message: [{type: 'COMMENT', html: 'Medium comment', text: 'a'.repeat(200)}], + message: [{type: 'COMMENT', html: 'Medium comment', text: 'a'.repeat(320)}], }), ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-medium`); expect( getItemType?.({ ...comment, reportActionID: 'long-comment', - message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(600)}], + message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(1200)}], }), ).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)}], + message: [{type: 'COMMENT', html: 'Extra long comment', text: 'a'.repeat(1201)}], }), ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-extra-long`); expect( @@ -582,7 +583,7 @@ 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', () => { + it('loads older pages when LegendList reaches the start and deduplicates the scroll fallback', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, @@ -601,12 +602,12 @@ describe('ReportActionsList (body)', () => { }); act(() => { - listProps?.onScroll?.(createScrollEvent(0)); + listProps?.onStartReached?.(); }); expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + expect(listProps?.onStartReachedThreshold).toBe(0.75); act(() => { - listProps?.onStartReached?.(); listProps?.onScroll?.(createScrollEvent(0)); }); expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index ea9a5a836dde..721b905757a3 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -156,4 +156,19 @@ describe('useEditMessage', () => { expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); + + it('uses the list-specific bottom scroll after deleting the newest message draft', () => { + const scrollToLastMessage = jest.fn(); + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true, scrollToLastMessage}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(scrollToLastMessage).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); }); From 332a7b34e1da18ee5f261c5e6db43e5901879f2b Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 18:24:54 +0200 Subject: [PATCH 003/109] fix: stabilize LegendList initial loading --- .../MoneyRequestReportView.tsx | 8 +-- src/components/ReportActionsSkeletonCover.tsx | 38 +++++++++++++ src/hooks/useReportActionsListModel.ts | 2 + src/pages/inbox/report/ReportActionsList.tsx | 17 ++---- .../report/ReportActionsLoadingSkeleton.tsx | 4 +- tests/ui/MoneyRequestReportViewTest.tsx | 46 +++++++++++++++- tests/ui/ReportActionsListTest.tsx | 55 +++++++++++++++++-- tests/ui/ReportActionsTest.tsx | 2 + tests/unit/ReportActionsSkeletonCoverTest.tsx | 41 ++++++++++++++ 9 files changed, 191 insertions(+), 22 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..088194236dc0 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 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..9390dcc5721b --- /dev/null +++ b/src/components/ReportActionsSkeletonCover.tsx @@ -0,0 +1,38 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import ReportActionsSkeletonView from './ReportActionsSkeletonView'; + +type ReportActionsSkeletonCoverProps = { + /** Whether the skeleton rows animate */ + shouldAnimate?: boolean; + + /** Whether the cover should be positioned over already-mounted content */ + shouldOverlay?: boolean; +}; + +/** Fills the report-actions viewport with a consistently positioned loading skeleton. */ +function ReportActionsSkeletonCover({shouldAnimate = true, shouldOverlay = false}: ReportActionsSkeletonCoverProps) { + const styles = useThemeStyles(); + + return ( + + + + ); +} + +export default ReportActionsSkeletonCover; diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 67e39bdd688d..0a2a9989d38b 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; @@ -146,6 +147,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 853923a4ee76..c5b652899539 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 MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; @@ -65,7 +66,6 @@ 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'; @@ -163,6 +163,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const { report, hasOnceLoadedReportActions, + isInitialReportLoadPending, hasOlderActions, hasNewerActions, isLoadingOlderReportActions, @@ -401,7 +402,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }); const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); - const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); + const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); const handleListLoad = () => { onLoad(); @@ -574,7 +575,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report // is cleared mid-session while the latch keeps the content mounted. if (!report) { - return ; + return ; } return ( @@ -610,6 +611,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyExtractor={keyExtractor} drawDistance={REPORT_ACTIONS_DRAW_DISTANCE} recycleItems + experimental_hideItemsUntilMeasured renderScrollComponent={renderActionSheetAwareScrollView} contentContainerStyle={styles.chatContentScrollView} onEndReached={loadNewerChatsAfterTransitions} @@ -637,14 +639,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct onLoad={handleListLoad} onContentSizeChange={() => trackVerticalScrolling(undefined)} /> - {shouldShowInitialViewportSkeleton && ( - - - - )} + {shouldShowInitialViewportSkeleton && } ); diff --git a/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx index 2fc926b894d0..d0e137110e40 100644 --- a/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx +++ b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx @@ -1,4 +1,4 @@ -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; import type {SkeletonName} from '@hooks/useCancelSendMessageSpanOnSkeleton'; @@ -28,7 +28,7 @@ type ReportActionsLoadingSkeletonProps = { function ReportActionsLoadingSkeleton({reportID, skeletonName, shouldAnimate = true, shouldMarkOpenReportEnd = true}: ReportActionsLoadingSkeletonProps) { useCancelSendMessageSpanOnSkeleton(reportID, skeletonName); useMarkOpenReportEndOnSkeleton(reportID, shouldMarkOpenReportEnd); - return ; + return ; } ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton'; diff --git a/tests/ui/MoneyRequestReportViewTest.tsx b/tests/ui/MoneyRequestReportViewTest.tsx index f0dc787734b5..00723a42cc92 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,37 @@ describe('MoneyRequestReportView', () => { expect(MoneyRequestReportUtils.shouldWaitForTransactions).toHaveBeenLastCalledWith(mockReport, [], mockReportLoadingState, false, false); }); + it('uses the 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(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + + it('uses a 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 bottom-padded cover while the app is loading', () => { + mockUseIsAppLoadPending.mockReturnValue(true); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + 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 35957761cbb8..4b54308c3c83 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -85,9 +85,9 @@ 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, isLoadingInitialReportActions = false) { return selector === reportActionsListLoadingStateSelector - ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false} + ? {hasOnceLoadedReportActions, isLoadingInitialReportActions, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false} : undefined; } @@ -181,6 +181,8 @@ type MockLegendListProps = { alignItemsAtEnd?: boolean; data?: OnyxTypes.ReportAction[]; drawDistance?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention + experimental_hideItemsUntilMeasured?: boolean; extraData?: unknown; getItemType?: (item: OnyxTypes.ReportAction) => string; initialScrollAtEnd?: boolean; @@ -231,6 +233,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; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -306,6 +309,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = false; mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); @@ -369,7 +373,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, mockIsLoadingInitialReportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -450,6 +454,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(); @@ -478,6 +483,47 @@ 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 viewport covered while OpenReport is queued despite stale stored loading state', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockUseIsReportLoadPending.mockReturnValue(true); + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + mockUseIsReportLoadPending.mockReturnValue(false); + view.rerender( + , + ); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + it('keeps the initial actions visible until the hydrated page is complete', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; @@ -533,6 +579,7 @@ describe('ReportActionsList (body)', () => { expect(listProps?.drawDistance).toBe(1500); expect(listProps?.recycleItems).toBe(true); + expect(listProps?.experimental_hideItemsUntilMeasured).toBe(true); }); it('groups comments by layout characteristics for measurement estimates', () => { @@ -1101,7 +1148,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions, !hasOnceLoadedReportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; diff --git a/tests/ui/ReportActionsTest.tsx b/tests/ui/ReportActionsTest.tsx index 18f7f63e2496..6099444f6a52 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(); diff --git a/tests/unit/ReportActionsSkeletonCoverTest.tsx b/tests/unit/ReportActionsSkeletonCoverTest.tsx new file mode 100644 index 000000000000..6392f50174b2 --- /dev/null +++ b/tests/unit/ReportActionsSkeletonCoverTest.tsx @@ -0,0 +1,41 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsSkeletonCover 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 report-actions skeleton', () => { + render(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ + flex: 1, + overflow: 'hidden', + justifyContent: 'flex-end', + paddingBottom: 16, + }); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + }); + + it('fills the parent when covering already-mounted content', () => { + render(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 10, + }); + }); +}); From 3a782f9f3f4d2c7279578837dc903e59aa8944dc Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 19:13:51 +0200 Subject: [PATCH 004/109] test: model content growth in pagination test --- tests/ui/PaginationTest.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index f393af39fb38..b569e00b5215 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -44,6 +44,11 @@ const LIST_CONTENT_SIZE = { height: 600, }; const LIST_END_OFFSET = LIST_CONTENT_SIZE.height - LIST_SIZE.height; +const PAGINATED_LIST_CONTENT_SIZE = { + ...LIST_CONTENT_SIZE, + height: LIST_CONTENT_SIZE.height * 2, +}; +const PAGINATED_LIST_END_OFFSET = PAGINATED_LIST_CONTENT_SIZE.height - LIST_SIZE.height; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); const REPORT_ID = '1'; @@ -59,14 +64,14 @@ function getReportScreen(reportID = REPORT_ID) { return screen.getByTestId(`report-screen-${reportID}`); } -function scrollToOffset(offset: number) { +function scrollToOffset(offset: number, contentSize = LIST_CONTENT_SIZE) { const hintText = TestHelper.translateLocal('sidebarScreen.listOfChatMessages'); fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { nativeEvent: { contentOffset: { y: offset, }, - contentSize: LIST_CONTENT_SIZE, + contentSize, layoutMeasurement: LIST_SIZE, }, }); @@ -394,9 +399,9 @@ 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(0); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(LIST_END_OFFSET); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. @@ -406,9 +411,9 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - scrollToOffset(0); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(LIST_END_OFFSET); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. From fa5194f7dd0afb68b1fd8a043078ecc07b756697 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 21:25:06 +0200 Subject: [PATCH 005/109] fix: stabilize report actions loading behavior --- .../MoneyRequestReportView.tsx | 7 ++++- src/components/ReportActionsSkeletonCover.tsx | 24 +++++++--------- src/hooks/useReportActionsListModel.ts | 2 -- src/pages/inbox/report/ReportActionsList.tsx | 17 ++--------- .../report/ReportActionsLoadingSkeleton.tsx | 7 ++++- tests/ui/ReportActionsListTest.tsx | 28 +++++++++++++++++-- tests/unit/ReportActionsSkeletonCoverTest.tsx | 10 +++++-- 7 files changed, 56 insertions(+), 39 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 088194236dc0..066762507a64 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -4,6 +4,7 @@ import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView'; import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -217,7 +218,11 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, } if (shouldShowEmptyActionsSkeleton) { - return ; + return ( + + + + ); } if (!report) { diff --git a/src/components/ReportActionsSkeletonCover.tsx b/src/components/ReportActionsSkeletonCover.tsx index 9390dcc5721b..9e3a4de0986e 100644 --- a/src/components/ReportActionsSkeletonCover.tsx +++ b/src/components/ReportActionsSkeletonCover.tsx @@ -1,36 +1,32 @@ 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 = { - /** Whether the skeleton rows animate */ - shouldAnimate?: boolean; + /** The skeleton content to place at the bottom of the report viewport */ + children?: ReactNode; - /** Whether the cover should be positioned over already-mounted content */ - shouldOverlay?: boolean; + /** Additional styles for the cover */ + style?: StyleProp; }; /** Fills the report-actions viewport with a consistently positioned loading skeleton. */ -function ReportActionsSkeletonCover({shouldAnimate = true, shouldOverlay = false}: ReportActionsSkeletonCoverProps) { +function ReportActionsSkeletonCover({children, style}: ReportActionsSkeletonCoverProps) { const styles = useThemeStyles(); return ( - + {children ?? } ); } diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 0a2a9989d38b..988354e83518 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -150,8 +150,6 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea isInitialReportLoadPending, hasOlderActions, hasNewerActions, - isLoadingOlderReportActions, - hasLoadingOlderReportActionsError, oldestReportActionID: currentReportOldestActionID, sortedAllReportActions, oldestUnreadReportAction, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index c5b652899539..fb5225af3373 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -166,8 +166,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isInitialReportLoadPending, hasOlderActions, hasNewerActions, - isLoadingOlderReportActions, - hasLoadingOlderReportActionsError, oldestReportActionID, sortedAllReportActions, oldestUnreadReportAction, @@ -193,18 +191,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct 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}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. @@ -639,7 +625,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct onLoad={handleListLoad} onContentSizeChange={() => trackVerticalScrolling(undefined)} /> - {shouldShowInitialViewportSkeleton && } + {shouldShowInitialViewportSkeleton && } ); @@ -653,6 +639,7 @@ function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsLis return ( ; + return ( + + + + ); } ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton'; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 4b54308c3c83..9aeb898162af 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -85,9 +85,9 @@ const mockUseConciergeDraftActions = useConciergeDraftActions as jest.MockedFunc const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; -function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true, isLoadingInitialReportActions = false) { +function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true, isLoadingInitialReportActions = false, isLoadingOlderReportActions = false) { return selector === reportActionsListLoadingStateSelector - ? {hasOnceLoadedReportActions, isLoadingInitialReportActions, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false} + ? {hasOnceLoadedReportActions, isLoadingInitialReportActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError: false} : undefined; } @@ -234,6 +234,7 @@ const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReport const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); let mockHasOnceLoadedReportActions = true; let mockIsLoadingInitialReportActions = false; +let mockIsLoadingOlderReportActions = false; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -310,6 +311,7 @@ describe('ReportActionsList (body)', () => { jest.clearAllMocks(); mockHasOnceLoadedReportActions = true; mockIsLoadingInitialReportActions = false; + mockIsLoadingOlderReportActions = false; mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); @@ -373,7 +375,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, mockIsLoadingInitialReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, mockIsLoadingInitialReportActions, mockIsLoadingOlderReportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -659,6 +661,26 @@ describe('ReportActionsList (body)', () => { }); expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + mockIsLoadingOlderReportActions = true; + view.rerender( + , + ); + mockIsLoadingOlderReportActions = false; + view.rerender( + , + ); + + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, reportActions: [...mockReportActions, olderMockReportAction], diff --git a/tests/unit/ReportActionsSkeletonCoverTest.tsx b/tests/unit/ReportActionsSkeletonCoverTest.tsx index 6392f50174b2..b33a729a9ea8 100644 --- a/tests/unit/ReportActionsSkeletonCoverTest.tsx +++ b/tests/unit/ReportActionsSkeletonCoverTest.tsx @@ -15,7 +15,11 @@ describe('ReportActionsSkeletonCover', () => { }); it('fills, clips, and bottom-aligns the report-actions skeleton', () => { - render(); + render( + + + , + ); expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ flex: 1, @@ -26,8 +30,8 @@ describe('ReportActionsSkeletonCover', () => { expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); }); - it('fills the parent when covering already-mounted content', () => { - render(); + it('accepts additional presentation styles', () => { + render(); expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ position: 'absolute', From 52df1e9fb652bb340607c463a9e59d20c478152b Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 11 Sep 2026 12:55:02 +0200 Subject: [PATCH 006/109] refactor: remove compiled report memoization --- .../FlatList/FlatList/index.ios.tsx | 35 +++----- .../MoneyRequestReportView.tsx | 79 ++++++++----------- .../useReportActionsNewActionLiveTail.ts | 6 +- 3 files changed, 48 insertions(+), 72 deletions(-) diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 7892a044825c..05bfe439c045 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import {FlatList} from 'react-native'; import type {CustomFlatListProps} from './types'; @@ -26,30 +26,21 @@ function CustomFlatList({ }: CustomFlatListProps) { const [isScrolling, setIsScrolling] = useState(false); const styles = useThemeStyles(); - const handleScrollBegin = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollBegin?.(event); - setIsScrolling(true); - }, - [onMomentumScrollBegin], - ); + const handleScrollBegin = (event: NativeSyntheticEvent) => { + onMomentumScrollBegin?.(event); + setIsScrolling(true); + }; - const handleScrollEnd = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollEnd?.(event); - setIsScrolling(false); - }, - [onMomentumScrollEnd], - ); + const handleScrollEnd = (event: NativeSyntheticEvent) => { + onMomentumScrollEnd?.(event); + setIsScrolling(false); + }; const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); + const handleScroll = (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + emitComposerScrollEvents(); + }; const listRef = useRef | null>(null); useFlatListHandle({ diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 066762507a64..232f71657bc4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -45,7 +45,7 @@ import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {PortalHost} from '@gorhom/portal'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; // We use Animated for all functionality related to wide RHP to make it easier // to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. // eslint-disable-next-line no-restricted-imports @@ -127,34 +127,23 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const {reportActions: unfilteredReportActions} = usePaginatedReportActions(reportID); - const reportActions = useMemo(() => { - return getFilteredReportActionsForReportView(unfilteredReportActions); - }, [unfilteredReportActions]); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const reportTransactions = useReportTransactionsCollection(reportID); - const transactions = useMemo(() => getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true), [reportTransactions, reportActions, isOffline]); + const transactions = getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true); - const visibleTransactions = useMemo(() => { - if (isOffline) { - return transactions; - } - - // When there are no pending delete transactions, which is most of the time, we can return the same transactions keeping the same reference avoiding extra work - const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - if (!hasPendingDelete) { - return transactions; - } - - return transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - }, [transactions, isOffline]); + // When there are no pending delete transactions, which is most of the time, return the same transactions to keep the same reference and avoid extra work. + const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const visibleTransactions = + isOffline || !hasPendingDelete ? transactions : transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const reportErrors = visibleTransactions.length === 1 && visibleTransactions.at(0)?.errors ? undefined : allReportErrors; const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const isReportLoadPending = useIsReportLoadPending(reportID); - const dismissReportCreationError = useCallback(() => { + const dismissReportCreationError = () => { goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)}); - }, [reportID]); + }; // Special case handling a report that is a transaction thread // If true we will use the standard `ReportActionsList` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList` @@ -172,33 +161,29 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const shouldShowWideRHPReceipt = visibleTransactions.length === 1 && !isSmallScreenWidth && !!transactionThreadReport; - const reportHeaderView = useMemo( - () => - isTransactionThreadView ? ( - { - if (!backToRoute) { - goBackFromSearchMoneyRequest(); - return; - } - Navigation.goBack(backToRoute); - }} - /> - ) : ( - { - if (!backToRoute) { - goBackFromSearchMoneyRequest(); - return; - } - Navigation.goBack(backToRoute); - }} - /> - ), - [backToRoute, isTransactionThreadView, report?.reportID], + const reportHeaderView = isTransactionThreadView ? ( + { + if (!backToRoute) { + goBackFromSearchMoneyRequest(); + return; + } + Navigation.goBack(backToRoute); + }} + /> + ) : ( + { + if (!backToRoute) { + goBackFromSearchMoneyRequest(); + return; + } + Navigation.goBack(backToRoute); + }} + /> ); // We need to cancel telemetry span when user leaves the screen before full report data is loaded diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index bb150ca6d99a..02b8d8665a76 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -19,7 +19,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; -import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; // In the component we are subscribing to the arrival of new actions. // As there is the possibility that there are multiple instances of a ReportScreen @@ -153,14 +153,14 @@ function useReportActionsNewActionLiveTail({ }); }); - const completeLiveTailPruneAfterScrollToBottom = useCallback(() => { + const completeLiveTailPruneAfterScrollToBottom = () => { if (liveTailJumpRef.current.stage !== 'await_prune') { return; } pruneReportActionPagesToNewestWindow(reportID, sortedAllReportActionsForPagination, reportActionPages); setTreatAsNoPaginationAnchor(false); liveTailJumpRef.current = {stage: 'idle'}; - }, [reportID, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor]); + }; useEffect(() => { liveTailJumpRef.current = {stage: 'idle'}; From c80ea5a0392b772e5f3713e203fd4b41ed0faa6c Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 14 Sep 2026 11:29:20 +0200 Subject: [PATCH 007/109] fix: allow report action menus to overflow list rows --- .../LegendList/allowLegendListItemOverflow.ts | 22 ++++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 15 ++++++-- tests/ui/ReportActionsListTest.tsx | 34 +++++++++++++++++++ tests/unit/LegendListItemOverflowTest.ts | 31 +++++++++++++++++ 4 files changed, 100 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 fb5225af3373..2497bc2283a6 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,4 +1,5 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; @@ -58,7 +59,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'; @@ -390,6 +391,16 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); 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); @@ -610,7 +621,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct onScroll={trackScrollPositionAndThreshold} onStartReached={loadOlderChatsOnStartReached} onStartReachedThreshold={PAGINATION_THRESHOLD} - onViewableItemsChanged={onViewableItemsChanged} + onViewableItemsChanged={handleViewableItemsChanged} extraData={extraData} key={listID} getItemType={getItemType} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 9aeb898162af..d01d3942bcaf 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,5 +1,7 @@ import {act, 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 */ @@ -26,6 +28,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'; @@ -61,6 +64,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(), @@ -84,6 +88,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, hasOnceLoadedReportActions = true, isLoadingInitialReportActions = false, isLoadingOlderReportActions = false) { return selector === reportActionsListLoadingStateSelector @@ -191,6 +196,8 @@ type MockLegendListProps = { maintainVisibleContentPosition?: boolean; onLoad?: () => void; onStartReachedThreshold?: number; + onContentSizeChange?: (width: number, height: number) => void; + onViewableItemsChanged?: (info: OnViewableItemsChangedInfo) => void; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -584,6 +591,33 @@ describe('ReportActionsList (body)', () => { expect(listProps?.experimental_hideItemsUntilMeasured).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); + }); +}); From 62a7c0d1e89d2d6474520d0f2390cc1ef92c95eb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 17 Sep 2026 22:29:20 +0200 Subject: [PATCH 008/109] fix: supply report name in modified expense tests --- tests/unit/ModifiedExpenseMessageTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index e5fc178f70d8..b9d5fc7b9732 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -465,6 +465,7 @@ describe('ModifiedExpenseMessage', () => { policyTags: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, + movedFromReportName: undefined, }); expect(result).toEqual(expectedResult); @@ -496,6 +497,7 @@ describe('ModifiedExpenseMessage', () => { policyTags: undefined, currentUserAccountID: CURRENT_USER_ACCOUNT_ID, currentUserLogin: CURRENT_USER_LOGIN, + movedFromReportName: undefined, }); expect(result).toEqual(expectedResult); From 09057d53cf34c7e24e12295594100be27a54ff0b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 13:55:30 +0200 Subject: [PATCH 009/109] fix: address report actions list review feedback --- patches/react-native/details.md | 2 +- src/components/FlashList/types.ts | 3 --- .../LegendList/allowLegendListItemOverflow.ts | 11 ++++------- .../MoneyRequestReportTransactionList.tsx | 5 +++-- .../MoneyRequestReportUnifiedList.tsx | 7 ++++--- .../inbox/report/ReportActionIndexContext.tsx | 1 + src/pages/inbox/report/ReportActionsList.tsx | 15 +++++---------- src/setup/telemetry/debugTransport.ts | 6 ++---- tests/unit/ActionListContextProviderTest.tsx | 7 +++---- tests/unit/LegendListItemOverflowTest.ts | 10 +++++++--- tests/unit/useReportScrollManagerTest.tsx | 9 ++++----- 11 files changed, 34 insertions(+), 42 deletions(-) delete mode 100644 src/components/FlashList/types.ts diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 7b69f3793020..135aa21f25fb 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -13,7 +13,7 @@ 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 +- Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 (closed without merging; the zero-sized LegendList anchor fix is not covered there) - 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/src/components/FlashList/types.ts b/src/components/FlashList/types.ts deleted file mode 100644 index 8563ec9959a4..000000000000 --- a/src/components/FlashList/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type ActionListRefType from '@pages/inbox/ActionListTypes'; - -export default ActionListRefType; diff --git a/src/components/LegendList/allowLegendListItemOverflow.ts b/src/components/LegendList/allowLegendListItemOverflow.ts index 5907b06815e1..a222d6d81055 100644 --- a/src/components/LegendList/allowLegendListItemOverflow.ts +++ b/src/components/LegendList/allowLegendListItemOverflow.ts @@ -1,3 +1,5 @@ +import {isRecord} from '@libs/ObjectUtils'; + import type {LegendListRef} from '@legendapp/list/react-native'; /** @@ -5,18 +7,13 @@ import type {LegendListRef} from '@legendapp/list/react-native'; * 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 { +function allowLegendListItemOverflow(list: LegendListRef | null, index: number): void { const itemContainer: unknown = list?.getState().elementAtIndex(index); if (!isRecord(itemContainer) || !isRecord(itemContainer.style) || typeof itemContainer.style.contain !== 'string') { - return false; + return; } 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/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 2ce48fa2bba1..9eadc6fbc552 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,5 +1,4 @@ import LinkButton from '@components/Button/composed/LinkButton'; -import type FlatListRefType from '@components/FlashList/types'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {SearchCustomColumnIds, SortOrder} from '@components/Search/types'; @@ -46,6 +45,8 @@ import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransact import isReportOpenInSuperWideRHP from '@navigation/helpers/isReportOpenInSuperWideRHP'; import Navigation from '@navigation/Navigation'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; + import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -184,7 +185,7 @@ type MoneyRequestReportTransactionListProps = { linkedReportActionID: string | undefined; /** Ref forwarded to the underlying FlashList. */ - listRef: FlatListRefType; + listRef: ActionListRefType; /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ onLastItemIndexChange?: (index: number) => void; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 81368bb1bada..da5999234ce7 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,8 +1,9 @@ import FlashList from '@components/FlashList'; -import type FlatListRefType from '@components/FlashList/types'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; + import variables from '@styles/variables'; import type * as OnyxTypes from '@src/types/onyx'; @@ -46,7 +47,7 @@ function unifiedListItemType(item: UnifiedListItem) { type MoneyRequestReportFlashListProps = FlashListProps & { /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ - ref: FlatListRefType; + ref: ActionListRefType; }; /** @@ -90,7 +91,7 @@ type MoneyRequestReportUnifiedListProps = { newTransactionID?: string; /** Ref to the underlying list, shared via the ActionList context. */ - listRef: FlatListRefType; + listRef: ActionListRefType; accessibilityLabel: string; diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index ae2fc03aa9a5..546d1cc5da83 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -6,6 +6,7 @@ import {createContext, useContext, useState} from 'react'; type ReportActionPosition = { index: number; isNewest: boolean; + /** LegendList reuses mounted rows for different actions; their item state must reset when the row is recycled. */ isRecycling?: boolean; }; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index d7ab0f26678e..30a1f7024bd0 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -7,6 +7,7 @@ import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; import useEnvironment from '@hooks/useEnvironment'; +import useInitial from '@hooks/useInitial'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; @@ -317,17 +318,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct 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; + const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); + const reportActionsToRender = hasOnceLoadedReportActions ? renderedVisibleReportActions : (initialReportActionsSnapshot ?? 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. @@ -392,7 +387,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); - const handleViewableItemsChanged = (info: OnViewableItemsChangedInfo) => { + const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { onViewableItemsChanged(info); for (const item of info.changed) { if (!item.isViewable) { @@ -626,7 +621,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct onScroll={trackScrollPositionAndThreshold} onStartReached={loadOlderChatsOnStartReached} onStartReachedThreshold={PAGINATION_THRESHOLD} - onViewableItemsChanged={handleViewableItemsChanged} + onViewableItemsChanged={updateVisibleItemOverflow} extraData={extraData} key={listID} getItemType={getItemType} diff --git a/src/setup/telemetry/debugTransport.ts b/src/setup/telemetry/debugTransport.ts index 3dfd00f51a85..08e51d397c05 100644 --- a/src/setup/telemetry/debugTransport.ts +++ b/src/setup/telemetry/debugTransport.ts @@ -1,3 +1,5 @@ +import {isRecord} from '@libs/ObjectUtils'; + import ONYXKEYS from '@src/ONYXKEYS'; import type {BaseTransportOptions, Envelope, Transport} from '@sentry/core'; @@ -43,10 +45,6 @@ function formatLogPrefix(category: string, op?: string): string { return `${SENTRY_LOG_PREFIX}[${category}]`; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function isString(value: unknown): value is string { return typeof value === 'string'; } diff --git a/tests/unit/ActionListContextProviderTest.tsx b/tests/unit/ActionListContextProviderTest.tsx index 37033b6ad9e5..d255306e5cdc 100644 --- a/tests/unit/ActionListContextProviderTest.tsx +++ b/tests/unit/ActionListContextProviderTest.tsx @@ -1,8 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; -import type FlatListRefType from '@components/FlashList/types'; - import {ActionListContextProvider, useActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; /** * `ActionListContextProvider` owns a private holder and exposes `registerListRef(ref)` / `getListRef()` @@ -18,7 +17,7 @@ describe('ActionListContextProvider', () => { it('getListRef() returns the same ref object after registerListRef(ref)', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); - const listRef: FlatListRefType = {current: null}; + const listRef: ActionListRefType = {current: null}; act(() => { result.current.registerListRef(listRef); @@ -29,7 +28,7 @@ describe('ActionListContextProvider', () => { it('getListRef() returns null again after registerListRef(null) (unmount path)', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); - const listRef: FlatListRefType = {current: null}; + const listRef: ActionListRefType = {current: null}; act(() => { result.current.registerListRef(listRef); diff --git a/tests/unit/LegendListItemOverflowTest.ts b/tests/unit/LegendListItemOverflowTest.ts index 4cbf62a78432..d6559318af66 100644 --- a/tests/unit/LegendListItemOverflowTest.ts +++ b/tests/unit/LegendListItemOverflowTest.ts @@ -14,18 +14,22 @@ describe('allowLegendListItemOverflow', () => { }), }); - expect(allowLegendListItemOverflow(list, 3)).toBe(true); + allowLegendListItemOverflow(list, 3); expect(style.contain).toBe('layout style'); }); it('does nothing for a native item container', () => { + const setNativeProps = jest.fn(); + const itemContainer = {setNativeProps}; const list = createMock({ getState: () => createMock>({ - elementAtIndex: () => ({setNativeProps: jest.fn()}), + elementAtIndex: () => itemContainer, }), }); - expect(allowLegendListItemOverflow(list, 3)).toBe(false); + allowLegendListItemOverflow(list, 3); + expect(itemContainer).toEqual({setNativeProps}); + expect(setNativeProps).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/useReportScrollManagerTest.tsx b/tests/unit/useReportScrollManagerTest.tsx index 6fcd7cb5c64a..6435a8dea5e7 100644 --- a/tests/unit/useReportScrollManagerTest.tsx +++ b/tests/unit/useReportScrollManagerTest.tsx @@ -1,10 +1,9 @@ import {act, renderHook} from '@testing-library/react-native'; -import type FlatListRefType from '@components/FlashList/types'; - import useReportScrollManager from '@hooks/useReportScrollManager'; import {ActionListContext, useActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; import type {ReactNode} from 'react'; @@ -24,17 +23,17 @@ function buildMockListRef() { getNativeScrollRef: jest.fn(() => undefined), }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const ref = {current: methods} as unknown as FlatListRefType; + const ref = {current: methods} as unknown as ActionListRefType; return {ref, methods}; } // Context value backed by a closure holder so registering a ref is visible to the manager's getListRef(). function buildContextValue() { - let held: FlatListRefType = null; + let held: ActionListRefType = null; return { scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, - registerListRef: (ref: FlatListRefType) => { + registerListRef: (ref: ActionListRefType) => { held = ref; }, getListRef: () => held, From ffa18ae1b0a609e2b992b2fc6bae186b5a0a6373 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 14:49:04 +0200 Subject: [PATCH 010/109] docs: explain report action recycling state --- src/pages/inbox/report/ReportActionIndexContext.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 546d1cc5da83..df37daebe0f3 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -6,7 +6,6 @@ import {createContext, useContext, useState} from 'react'; type ReportActionPosition = { index: number; isNewest: boolean; - /** LegendList reuses mounted rows for different actions; their item state must reset when the row is recycled. */ isRecycling?: boolean; }; @@ -23,6 +22,12 @@ const ReportActionIndexContext = createContext({index: 0, /** Lets shared list implementations provide their own reliable way to reach the newest action. */ const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); +/** + * Each list passes the index from its row renderer and marks the last action as newest. The + * LegendList caller also sets isRecycling because it can reuse a mounted row for another action. + * Descendants use that flag to reset transient state (such as open menus or PDF load errors) + * with useRecyclingState instead of carrying it over to the next action in that row. + */ function ReportActionPositionContextProvider({children, index, isNewest, isRecycling}: PropsWithChildren) { return {children}; } From f12b575e2308d26cf321451dd413904b28185590 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 22 Sep 2026 14:09:08 +0200 Subject: [PATCH 011/109] fix: remove duplicate report list spacing --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- tests/ui/ReportActionsListTest.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index afe11d56aa18..4f465e0903ca 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -620,7 +620,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct recycleItems experimental_hideItemsUntilMeasured renderScrollComponent={renderActionSheetAwareScrollView} - contentContainerStyle={styles.chatContentScrollView} + contentContainerStyle={[styles.chatContentScrollView, styles.pb0]} onEndReached={loadNewerChatsAfterTransitions} onEndReachedThreshold={PAGINATION_THRESHOLD} ListHeaderComponent={listFooterComponent} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 72718bd0b8dd..e6a49ee00bf6 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -30,8 +30,10 @@ 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 type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; +import {StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -184,6 +186,7 @@ jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => nul type MockLegendListProps = { alignItemsAtEnd?: boolean; + contentContainerStyle?: StyleProp; data?: OnyxTypes.ReportAction[]; drawDistance?: number; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -421,6 +424,13 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); }); + it('does not duplicate the composer spacing inside the chronological list', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + expect(StyleSheet.flatten(getCapturedListProps()?.contentContainerStyle)?.paddingBottom).toBe(0); + }); + it('initially aligns the seed page to the end', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; From 34b345d4c529ad17ab7c92612c6bd59784d7a8c6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 22 Sep 2026 14:10:59 +0200 Subject: [PATCH 012/109] fix: synchronize sent comment scrolling --- .../useReportActionsNewActionLiveTail.ts | 27 ++++++++++++++----- .../useReportActionsNewActionLiveTailTest.ts | 26 +++++++++++++++--- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index 02b8d8665a76..cf01b300b280 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -19,7 +19,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; -import {useEffect, useEffectEvent, useRef, useState} from 'react'; +import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; // In the component we are subscribing to the arrival of new actions. // As there is the possibility that there are multiple instances of a ReportScreen @@ -93,6 +93,16 @@ function useReportActionsNewActionLiveTail({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const liveTailJumpRef = useRef<{stage: LiveTailJumpStage}>({stage: 'idle'}); const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); + const [pendingScrollActionID, setPendingScrollActionID] = useState(); + + const setShouldScrollToBottom = useCallback((enabled: boolean) => { + setIsScrollToBottomEnabled(enabled); + if (!enabled) { + setPendingScrollActionID(undefined); + } + }, []); + + const isPendingScrollActionRendered = !!pendingScrollActionID && renderedVisibleReportActions.some((item) => item.reportActionID === pendingScrollActionID); const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { TransitionTracker.runAfterTransitions({ @@ -102,6 +112,11 @@ function useReportActionsNewActionLiveTail({ if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { return; } + // Pusher echoes omit the action payload and can represent edits or deletions. Local sends + // notify with the optimistic action, which lets us wait until that exact row is rendered. + if (!action) { + return; + } if (!hasNewestReportAction && !isFromCurrentUser) { if (Navigation.getReportRHPActiveRoute()) { return; @@ -147,7 +162,7 @@ function useReportActionsNewActionLiveTail({ } } else { setIsFloatingMessageCounterVisible(false); - setIsScrollToBottomEnabled(true); + setPendingScrollActionID(action.reportActionID); } }, }); @@ -205,9 +220,9 @@ function useReportActionsNewActionLiveTail({ setIsFloatingMessageCounterVisible(false); // Defer so this effect does not synchronously chain a second render from setState (eslint react-hooks/set-state-in-effect). queueMicrotask(() => { - setIsScrollToBottomEnabled(true); + setShouldScrollToBottom(true); }); - }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible]); + }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible, setShouldScrollToBottom]); useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? @@ -237,8 +252,8 @@ function useReportActionsNewActionLiveTail({ }, [reportID]); return { - isScrollToBottomEnabled, - setIsScrollToBottomEnabled, + isScrollToBottomEnabled: isScrollToBottomEnabled || isPendingScrollActionRendered, + setIsScrollToBottomEnabled: setShouldScrollToBottom, completeLiveTailPruneAfterScrollToBottom, }; } diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index cf372cf3122c..635d82eddb92 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -120,15 +120,33 @@ 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}))); + it('requests one post-render scroll once the sent comment is rendered', () => { + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({hasNewerActions: false, hasNewestReportAction: true}), + }); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, action); }); - expect(result.current.isScrollToBottomEnabled).toBe(true); + expect(result.current.isScrollToBottomEnabled).toBe(false); expect(reportScrollManager.scrollToBottom).not.toHaveBeenCalled(); + + rerender(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); + + expect(result.current.isScrollToBottomEnabled).toBe(true); + }); + + it('does not scroll for a payload-less realtime echo', () => { + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); + + act(() => { + newActionHandler?.(true, undefined); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(mockOpenReport).not.toHaveBeenCalled(); }); it('does not queue a bottom scroll that would compete with a report-preview target', () => { From da3b443d6bfc4905a14cf31a1ee902f470a3bab8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 22 Sep 2026 14:12:25 +0200 Subject: [PATCH 013/109] fix: preserve hydrated report list state --- src/hooks/useReportActionsListModel.ts | 5 ++++- src/pages/inbox/report/ReportActionsList.tsx | 5 ++++- tests/ui/ReportActionsListTest.tsx | 23 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 156fb84db12e..ef0c8d9b26cd 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -12,6 +12,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import {useRoute} from '@react-navigation/native'; import {useIsAppLoadPending} from './useInFlightRequests'; +import useInitial from './useInitial'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; @@ -54,7 +55,9 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, { selector: reportActionsListLoadingStateSelector, }); - const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; + // This flag is monotonic for one report visit. Keep it true if the RAM-only entry is briefly + // absent while optimistic writes are merged, so the hydrated list never returns to its loading state. + const hasOnceLoadedReportActions = useInitial(reportLoadingState?.hasOnceLoadedReportActions ? true : undefined); const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; const isInitialReportLoadPending = !hasOnceLoadedReportActions && (isReportLoadPending || isLoadingInitialReportActions !== false); const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 4f465e0903ca..aeb493bf939d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -665,7 +665,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct */ function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsListProps) { return ( - + { expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(true); }); + it('keeps a hydrated list mounted when its RAM-only loading entry briefly regresses', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + it('limits the render buffer and enables item recycling', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); From 55cb72a5d4646ae1ee1af1b325b33cd022a73488 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 22 Sep 2026 14:13:56 +0200 Subject: [PATCH 014/109] fix: refresh recycled unread marker rows --- src/pages/inbox/report/ReportActionsList.tsx | 4 +--- tests/ui/ReportActionsListTest.tsx | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index aeb493bf939d..2dae9cc9a702 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -14,7 +14,6 @@ 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 useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; @@ -161,7 +160,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isProduction} = useEnvironment(); const { @@ -538,7 +536,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // Native mobile does not render updates flatlist the changes even though component did update called. // To notify there something changes we can use extraData prop to flatlist const extraData = [ - shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, + unreadMarkerReportActionID, isArchivedNonExpenseReport(report, isReportArchived), draftReportActionID, draftMessageHTML, diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index d87bcec39a02..0d6cf09b37f8 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -223,7 +223,7 @@ const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendLi const getRenderedReportActionsListItemProps = ( reportAction: OnyxTypes.ReportAction, index = 0, -): {shouldDisableContextMenuForConciergeDraft?: boolean; isLatestConciergeFeedbackAction?: boolean} => { +): {shouldDisableContextMenuForConciergeDraft?: boolean; isLatestConciergeFeedbackAction?: boolean; shouldDisplayNewMarker?: boolean} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); if (!React.isValidElement<{children: React.ReactNode}>(renderedItem)) { @@ -244,6 +244,7 @@ const getRenderedReportActionsListItemProps = ( const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); +const mockUseUnreadMarker: jest.Mock = jest.requireMock('@hooks/useUnreadMarker'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); let mockHasOnceLoadedReportActions = true; let mockIsLoadingInitialReportActions = false; @@ -352,6 +353,7 @@ describe('ReportActionsList (body)', () => { mockIsLoadingInitialReportActions = false; mockIsLoadingOlderReportActions = false; mockShouldCallLegendListOnLoad = true; + mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}); mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -431,6 +433,19 @@ describe('ReportActionsList (body)', () => { expect(StyleSheet.flatten(getCapturedListProps()?.contentContainerStyle)?.paddingBottom).toBe(0); }); + it('invalidates a recycled row when the unread marker changes on a wide layout', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: '2', unreadMarkerReportActionIndex: 0}); + renderReportActionsList(); + + expect(getCapturedListProps()?.extraData).toEqual(expect.arrayContaining(['2'])); + const markerAction = getCapturedVisibleActions()?.find((action) => action.reportActionID === '2'); + if (!markerAction) { + throw new Error('Expected unread marker action fixture'); + } + expect(getRenderedReportActionsListItemProps(markerAction, 1).shouldDisplayNewMarker).toBe(true); + }); + it('initially aligns the seed page to the end', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; From 8188df1a366d2ae10ebea53b29f8fa9795e67cbc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:27:46 +0200 Subject: [PATCH 015/109] fix: keep report actions live across incomplete loads --- src/pages/inbox/report/ReportActionsList.tsx | 19 +++--- tests/ui/ReportActionsListTest.tsx | 67 ++++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 2dae9cc9a702..8353a1599357 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -322,7 +322,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // already-visible page mounted until hydration finishes instead of exposing intermediate estimated // layouts. The hydrated list then mounts from scratch using the full dataset. const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); - const reportActionsToRender = hasOnceLoadedReportActions ? renderedVisibleReportActions : (initialReportActionsSnapshot ?? renderedVisibleReportActions); + const shouldKeepInitialSnapshot = isInitialReportLoadPending && !isOffline && !showFullHistory; + const reportActionsToRender = shouldKeepInitialSnapshot ? (initialReportActionsSnapshot ?? renderedVisibleReportActions) : 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. @@ -385,7 +386,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }); const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); - const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + // A warm report already has its complete action page. Only an initial visit needs a cover + // while the first page finishes loading and LegendList positions it. + const [shouldCoverInitialViewport] = useState(!hasOnceLoadedReportActions); + const shouldShowInitialViewportSkeleton = !isOffline && shouldCoverInitialViewport && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { onViewableItemsChanged(info); @@ -533,8 +537,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ); }; - // Native mobile does not render updates flatlist the changes even though component did update called. - // To notify there something changes we can use extraData prop to flatlist + // Ensure row presentation updates when these values change without changing the action data. const extraData = [ unreadMarkerReportActionID, isArchivedNonExpenseReport(report, isReportArchived), @@ -544,7 +547,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct latestConciergeFeedbackActionID, ]; - const listHeaderComponent = ( + const latestActionsFooter = ( action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; + const offlineHistoryHeader = shouldShowOfflineSkeleton ? : undefined; const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy, @@ -621,8 +624,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct contentContainerStyle={[styles.chatContentScrollView, styles.pb0]} onEndReached={loadNewerChatsAfterTransitions} onEndReachedThreshold={PAGINATION_THRESHOLD} - ListHeaderComponent={listFooterComponent} - ListFooterComponent={listHeaderComponent} + ListHeaderComponent={offlineHistoryHeader} + ListFooterComponent={latestActionsFooter} ListFooterComponentStyle={shouldBeAlignedToTop ? styles.flex1 : undefined} keyboardShouldPersistTaps="handled" onLayout={recordTimeToMeasureItemLayout} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 0569681d2e57..5148b4c108af 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -527,6 +527,33 @@ describe('ReportActionsList (body)', () => { expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); }); + it('forwards the list load event so initial scroll tracking can resume', () => { + // Given a report with its action list ready to mount. + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + const onLoad = mockUseReportActionsScroll.mock.results.at(-1)?.value.onLoad; + expect(onLoad).not.toHaveBeenCalled(); + + // When LegendList reports that its first layout is complete. + act(() => getCapturedListProps()?.onLoad?.()); + + // Then the scroll hook receives that event to finish initial positioning. + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it('shows a previously hydrated report without replaying the loading cover', () => { + // Given a report that was already loaded before this list mounted + mockUseNetwork.mockReturnValue({isOffline: false}); + mockShouldCallLegendListOnLoad = false; + + // When the user opens the report again + renderReportActionsList(); + + // Then cached actions stay visible while LegendList completes its layout + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + it('releases the initial viewport cover after a terminal OpenReport failure', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; @@ -571,6 +598,7 @@ describe('ReportActionsList (body)', () => { it('keeps the initial actions visible until the hydrated page is complete', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; const groupingSpy = jest.spyOn(ReportActionsUtils, 'isConsecutiveActionMadeByPreviousActor'); const view = renderReportActionsList(); @@ -615,6 +643,45 @@ describe('ReportActionsList (body)', () => { expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(true); }); + it.each([ + {isOffline: true, isLoadingInitialReportActions: true}, + {isOffline: false, isLoadingInitialReportActions: false}, + ])('keeps actions live after a first load cannot complete: %p', async ({isOffline, isLoadingInitialReportActions}) => { + // Given cached actions with no successful OpenReport response + mockUseNetwork.mockReturnValue({isOffline}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = isLoadingInitialReportActions; + const view = renderReportActionsList(); + + // When an optimistic action is added while offline or after a terminal load failure + const existingAction = mockReportActions.at(1); + if (!existingAction) { + throw new Error('Expected a cached report action'); + } + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [ + { + ...existingAction, + reportActionID: 'new-comment', + created: '2023-01-03', + }, + ...mockReportActions, + ], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the new action appears without waiting for hydration + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('new-comment'); + }); + it('keeps a hydrated list mounted when its RAM-only loading entry briefly regresses', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); const view = renderReportActionsList(); From 9bd382a51488769267b48df5b44a5fa5e9a1b00f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:28:15 +0200 Subject: [PATCH 016/109] fix: preserve local action scrolling without realtime echo jumps --- src/libs/actions/Report/index.ts | 2 +- .../actions/Report/reportActionSubscribers.ts | 8 ++- .../useReportActionsNewActionLiveTail.ts | 27 +++++++-- .../unit/libs/reportActionSubscribersTest.ts | 15 ++++- .../useReportActionsNewActionLiveTailTest.ts | 55 +++++++++++++++---- 5 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 59ffb84762ba..68c0fd0ed708 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5294,7 +5294,7 @@ function showReportActionNotification( LocalNotification.showCommentNotification(report, reportAction, onClick, derivedReportName); } - notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID); + notifyNewAction(reportID, undefined, reportAction.actorAccountID === currentUserAccountID, 'realtime'); } /** Clear the errors associated with the IOUs of a given report. */ diff --git a/src/libs/actions/Report/reportActionSubscribers.ts b/src/libs/actions/Report/reportActionSubscribers.ts index 8fc4644ac0eb..66b78ef98872 100644 --- a/src/libs/actions/Report/reportActionSubscribers.ts +++ b/src/libs/actions/Report/reportActionSubscribers.ts @@ -3,7 +3,8 @@ */ import type ReportAction from '@src/types/onyx/ReportAction'; -type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined) => void; +type ActionEventSource = 'local' | 'realtime'; +type SubscriberCallback = (isFromCurrentUser: boolean, reportAction: ReportAction | undefined, source: ActionEventSource) => void; type ActionSubscriber = { reportID: string; @@ -26,7 +27,7 @@ function subscribeToNewActionEvent(reportID: string, callback: SubscriberCallbac } /** Notify the ReportActionsList that a new comment has arrived */ -function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean) { +function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean, source: ActionEventSource = 'local') { if (!reportID) { return; } @@ -34,9 +35,10 @@ function notifyNewAction(reportID: string | string[] | undefined, reportAction: for (const id of ids) { const actionSubscriber = newActionSubscribers.find((subscriber) => subscriber.reportID === id); if (actionSubscriber) { - actionSubscriber.callback(isFromCurrentUser, reportAction); + actionSubscriber.callback(isFromCurrentUser, reportAction, source); } } } export {subscribeToNewActionEvent, notifyNewAction}; +export type {ActionEventSource}; diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index c6288f0532dd..fb76130ce1e9 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -5,6 +5,7 @@ import type useReportScrollManager from '@hooks/useReportScrollManager'; import type {OpenReportActionParams} from '@libs/actions/Report'; import {openReport, pruneReportActionPagesToNewestWindow} from '@libs/actions/Report'; import {subscribeToNewActionEvent} from '@libs/actions/Report/reportActionSubscribers'; +import type {ActionEventSource} from '@libs/actions/Report/reportActionSubscribers'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -60,6 +61,7 @@ type UseReportActionsNewActionLiveTailParams = { }; type LiveTailJumpStage = 'idle' | 'open_report' | 'await_scroll' | 'await_prune'; +const PENDING_SENT_ACTION_TIMEOUT_MS = 5000; /** * Owns subscribe-to-new-action scrolling, live-tail jump (openReport → scroll → prune), and the @@ -105,7 +107,18 @@ function useReportActionsNewActionLiveTail({ const isPendingScrollActionRendered = !!pendingScrollActionID && renderedVisibleReportActions.some((item) => item.reportActionID === pendingScrollActionID); - const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { + useEffect(() => { + if (!pendingScrollActionID) { + return; + } + + // The optimistic row normally arrives immediately. A filtered or failed row must not + // scroll the reader much later when pagination or reconnecting makes it visible. + const timeout = setTimeout(() => setPendingScrollActionID(undefined), PENDING_SENT_ACTION_TIMEOUT_MS); + return () => clearTimeout(timeout); + }, [pendingScrollActionID]); + + const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action: OnyxTypes.ReportAction | undefined, source: ActionEventSource) => { TransitionTracker.runAfterTransitions({ callback: () => { // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where @@ -113,9 +126,9 @@ function useReportActionsNewActionLiveTail({ if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { return; } - // Pusher echoes omit the action payload and can represent edits or deletions. Local sends - // notify with the optimistic action, which lets us wait until that exact row is rendered. - if (!action) { + // A realtime echo can represent an edit or deletion, whereas local payload-less + // notifications from money requests still need the usual live-tail behavior. + if (!action && source === 'realtime') { return; } if (!hasNewestReportAction && !isFromCurrentUser) { @@ -163,7 +176,11 @@ function useReportActionsNewActionLiveTail({ } } else { setIsFloatingMessageCounterVisible(false); - setPendingScrollActionID(action.reportActionID); + if (action?.reportActionID) { + setPendingScrollActionID(action.reportActionID); + } else { + setShouldScrollToBottom(true); + } } }, }); diff --git a/tests/unit/libs/reportActionSubscribersTest.ts b/tests/unit/libs/reportActionSubscribersTest.ts index 262557d19a03..da2401146bda 100644 --- a/tests/unit/libs/reportActionSubscribersTest.ts +++ b/tests/unit/libs/reportActionSubscribersTest.ts @@ -7,7 +7,7 @@ describe('subscribeToNewActionEvent', () => { notifyNewAction('1', undefined, true); - expect(callback).toHaveBeenCalledWith(true, undefined); + expect(callback).toHaveBeenCalledWith(true, undefined, 'local'); unsubscribe(); }); @@ -56,4 +56,17 @@ describe('subscribeToNewActionEvent', () => { expect(callback).not.toHaveBeenCalled(); }); + + test('identifies realtime notifications separately from local actions', () => { + // Given a subscriber listening to the current report + const callback = jest.fn(); + const unsubscribe = subscribeToNewActionEvent('1', callback); + + // When a realtime echo arrives without an action payload + notifyNewAction('1', undefined, true, 'realtime'); + + // Then the subscriber can distinguish it from a local money request + expect(callback).toHaveBeenCalledWith(true, undefined, 'realtime'); + unsubscribe(); + }); }); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index a3257b4292d3..7930bd25809d 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -13,7 +13,7 @@ const mockNavigationSetParams = jest.fn(); const mockGlobalSetParams = jest.fn(); const mockOpenReport = jest.fn(); const mockLogAlert = jest.fn(); -let newActionHandler: ((isFromCurrentUser: boolean, action?: ReportAction) => void) | undefined; +let newActionHandler: ((isFromCurrentUser: boolean, action: ReportAction | undefined, source: 'local' | 'realtime') => void) | undefined; jest.mock('@libs/Log', () => ({ __esModule: true, @@ -73,7 +73,7 @@ jest.mock('@libs/actions/Report', () => ({ })); jest.mock('@libs/actions/Report/reportActionSubscribers', () => ({ - subscribeToNewActionEvent: (_reportID: string, callback: (isFromCurrentUser: boolean, action?: ReportAction) => void) => { + subscribeToNewActionEvent: (_reportID: string, callback: (isFromCurrentUser: boolean, action: ReportAction | undefined, source: 'local' | 'realtime') => void) => { newActionHandler = callback; return jest.fn(); }, @@ -130,7 +130,7 @@ describe('useReportActionsNewActionLiveTail', () => { }); act(() => { - newActionHandler?.(true, action); + newActionHandler?.(true, action, 'local'); }); expect(result.current.isScrollToBottomEnabled).toBe(false); @@ -145,19 +145,54 @@ describe('useReportActionsNewActionLiveTail', () => { const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); act(() => { - newActionHandler?.(true, undefined); + newActionHandler?.(true, undefined, 'realtime'); }); expect(result.current.isScrollToBottomEnabled).toBe(false); expect(mockOpenReport).not.toHaveBeenCalled(); }); + it('keeps the live-tail jump for a local action without a report action payload', () => { + // Given a report showing an older page + renderHook(() => useReportActionsNewActionLiveTail(buildParams())); + + // When a local money request sends a payload-less notification + act(() => { + newActionHandler?.(true, undefined, 'local'); + }); + + // Then the report opens its latest page + expect(mockOpenReport).toHaveBeenCalledTimes(1); + }); + + it('expires a sent-comment scroll request when the action never enters the list', () => { + // Given a sent action that is filtered out or fails to appear + jest.useFakeTimers(); + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({hasNewerActions: false, hasNewestReportAction: true}), + }); + act(() => { + newActionHandler?.(true, action, 'local'); + }); + + // When the action finally appears well after the send attempt + act(() => { + jest.advanceTimersByTime(10000); + }); + rerender(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); + + // Then the stale request does not pull the reader to the bottom + expect(result.current.isScrollToBottomEnabled).toBe(false); + jest.useRealTimers(); + }); + 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); + newActionHandler?.(true, preview, 'local'); }); expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); @@ -169,7 +204,7 @@ describe('useReportActionsNewActionLiveTail', () => { renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({conciergeChat})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({conciergeChat})); @@ -179,7 +214,7 @@ describe('useReportActionsNewActionLiveTail', () => { renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({isSelfTourViewed: true, hasCompletedGuidedSetupFlow: false})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); // The onboarding flags are threaded straight through so guided-setup optimistic data is derived from real Onyx @@ -191,7 +226,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams()}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); expect(mockOpenReport).toHaveBeenCalledTimes(1); @@ -214,7 +249,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({setTreatAsNoPaginationAnchor})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); expect(() => @@ -236,7 +271,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({setTreatAsNoPaginationAnchor})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); rerender( From f0b046a56553c2af10dc9a7e200d0f361ec0e3cc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:28:36 +0200 Subject: [PATCH 017/109] fix: key recycled media state by action and image --- src/components/ThumbnailImage.tsx | 6 +- src/pages/inbox/report/ReportActionItem.tsx | 13 ++--- tests/unit/ThumbnailImageTest.tsx | 61 +++++++++++++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 tests/unit/ThumbnailImageTest.tsx diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 96fccc69e533..2471ac4ad8af 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -101,7 +101,9 @@ function ThumbnailImage({ const failedToLoad = failedLoadKey !== null && failedLoadKey.url === previewSourceURL && failedLoadKey.isOffline === isOffline; const cachedDimensions = shouldDynamicallyResize && typeof previewSourceURL === 'string' ? thumbnailDimensionsCache.get(previewSourceURL) : null; - const [imageDimensions, setImageDimensions] = useState({width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight}); + const [measuredDimensions, setMeasuredDimensions] = useState<{url: string | ImageSourcePropType; width: number; height: number} | null>(null); + const imageDimensions = + measuredDimensions?.url === previewSourceURL ? measuredDimensions : {width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight}; const {thumbnailDimensionsStyles} = useThumbnailDimensions(imageDimensions.width, imageDimensions.height); const StyleUtils = useStyleUtils(); @@ -114,7 +116,7 @@ function ThumbnailImage({ thumbnailDimensionsCache.set(previewSourceURL, {width, height}); } - setImageDimensions({width, height}); + setMeasuredDimensions({url: previewSourceURL, width, height}); }; const sizeStyles = shouldDynamicallyResize ? [thumbnailDimensionsStyles] : [styles.w100, styles.h100]; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index c031179b2a36..df94cd75d2e7 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -80,7 +80,6 @@ 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'; @@ -219,10 +218,7 @@ function ReportActionItem({ const {isActiveReportAction: isActiveReactionListReportAction, hideReactionList} = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(AttachmentModalContext); const popoverAnchorRef = useRef>(null); - const downloadedPreviews = useRef([]); - useRecyclingEffect(() => { - downloadedPreviews.current = []; - }); + const downloadedPreviews = useRef<{reportActionID: string; urls: string[]}>({reportActionID: action.reportActionID, urls: []}); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const [isReportActionActive, setIsReportActionActive] = useReportActionItemState(!!isReportActionLinked); @@ -342,11 +338,14 @@ function ReportActionItem({ } const urls = extractLinksFromMessageHtml(action); - if (deepEqual(downloadedPreviews.current, urls) || action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + if ( + (downloadedPreviews.current.reportActionID === action.reportActionID && deepEqual(downloadedPreviews.current.urls, urls)) || + action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE + ) { return; } - downloadedPreviews.current = urls; + downloadedPreviews.current = {reportActionID: action.reportActionID, urls}; expandURLPreview(reportID, action.reportActionID); }, [action, reportID]); diff --git a/tests/unit/ThumbnailImageTest.tsx b/tests/unit/ThumbnailImageTest.tsx new file mode 100644 index 000000000000..e98a1b0284b4 --- /dev/null +++ b/tests/unit/ThumbnailImageTest.tsx @@ -0,0 +1,61 @@ +import {act, render} from '@testing-library/react-native'; + +import ThumbnailImage from '@components/ThumbnailImage'; + +import React from 'react'; + +const mockUseThumbnailDimensions = jest.fn((width: number, height: number) => ({thumbnailDimensionsStyles: {width, height}})); +let mockOnMeasure: ((dimensions: {width: number; height: number}) => void) | undefined; + +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useStyleUtils', () => () => ({})); +jest.mock('@hooks/useTheme', () => () => ({border: '#000'})); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useThumbnailDimensions', () => (width: number, height: number) => mockUseThumbnailDimensions(width, height)); +jest.mock('@components/ImageWithSizeCalculation', () => ({ + __esModule: true, + default: ({onMeasure}: {onMeasure: typeof mockOnMeasure}) => { + mockOnMeasure = onMeasure; + return null; + }, +})); + +describe('ThumbnailImage', () => { + beforeEach(() => { + mockUseThumbnailDimensions.mockClear(); + mockOnMeasure = undefined; + }); + + it('uses dimensions for the current URL when a recycled row shows another image', () => { + // Given a measured thumbnail for the first image. + const view = render( + , + ); + act(() => mockOnMeasure?.({width: 400, height: 100})); + expect(mockUseThumbnailDimensions).toHaveBeenLastCalledWith(400, 100); + + // When the same component instance is reused for a second image. + view.rerender( + , + ); + + // Then the second image starts with its own dimensions, rather than the first image's. + expect(mockUseThumbnailDimensions).toHaveBeenLastCalledWith(200, 200); + + // When the first image returns, its measured dimensions are restored from the URL cache. + view.rerender( + , + ); + expect(mockUseThumbnailDimensions).toHaveBeenLastCalledWith(400, 100); + }); +}); From a8fc4fad1e164ab663364a587844d99d215c4e73 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:28:54 +0200 Subject: [PATCH 018/109] fix: keep highlighted final action in view --- src/hooks/useReportActionsScroll.ts | 5 ++++- tests/unit/useReportActionsScrollTest.tsx | 26 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 3f5ce23335be..5417ac6e6aa9 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -345,7 +345,10 @@ function useReportActionsScroll({ let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; if (targetIndex >= 0) { initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + // The final action can be taller than the viewport. Aligning its top can leave the + // highlighted content below the screen; at the tail, show the action's end instead. + initialScrollIndexParams = + targetIndex === renderedVisibleReportActions.length - 1 ? {viewPosition: 1, viewOffset: 0} : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { initialScrollIndex = 0; } diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 118361255128..f73c3d40d140 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -278,12 +278,25 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); - it('positions a linked report action at chronological index zero', async () => { + it('bottom-aligns a linked report action when it is the final item', async () => { mockRouteParams = {reportActionID: LINKED_ACTION_ID}; const linkedAction = makeAction(LINKED_ACTION_ID); const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 1, viewOffset: 0}); + }); + + it('top-aligns a linked report action when later actions follow it', async () => { + // Given a linked action followed by another action in chronological order. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction(LINKED_ACTION_ID), makeAction('999')]; + + // When the list chooses its initial position. + const {result} = await renderScroll({sortedVisibleReportActions: actions.toReversed(), renderedVisibleReportActions: actions}); + + // Then the linked action is aligned below the header. expect(result.current.initialScrollIndex).toBe(0); expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); @@ -330,6 +343,17 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); + + it('does not clear the latest route parameter when it was never set', async () => { + // Given an ordinary report open without the X Replies flag. + mockRouteParams = {}; + + // When its scroll hook mounts. + await renderScroll(); + + // Then it leaves unrelated route parameters alone. + expect(mockSetParams).not.toHaveBeenCalledWith({shouldScrollToLatest: undefined}); + }); }); describe('scrollToBottomAndMarkReportAsRead', () => { From 6e7f0455b710029c42fbbf99d9c8d36a7fe29858 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:29:14 +0200 Subject: [PATCH 019/109] test: verify unread and read thresholds from saved offset --- tests/unit/ReportActionsListThresholdTest.tsx | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index 61c2508361be..4408d9f440cb 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -38,19 +38,14 @@ type ScrollEvent = { }; }; type CapturedListProps = { - maintainVisibleContentPosition?: boolean | {data: boolean}; onScroll?: (event: ScrollEvent) => void; }; -// 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 = {}; - -// Whether the captured LegendList configuration enables data-based maintain-visible-content-position. -function isMvcpEnabled() { - const config = capturedListProps.maintainVisibleContentPosition; - return config === true || (typeof config === 'object' && config.data); -} +jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null}))); +jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); +const mockUseUnreadMarker: jest.Mock = jest.requireMock('@hooks/useUnreadMarker'); +const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); jest.mock('@legendapp/list/react-native', () => { const {forwardRef} = jest.requireActual('react'); @@ -128,12 +123,14 @@ async function renderList(initialOffset: number) { , ); - await waitFor(() => expect(capturedListProps.maintainVisibleContentPosition).toBeDefined()); + await waitFor(() => expect(capturedListProps.onScroll).toBeDefined()); return utils; } beforeEach(async () => { capturedListProps = {}; + mockUseUnreadMarker.mockClear(); + mockUseMarkAsRead.mockClear(); setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); await act(async () => { @@ -158,31 +155,43 @@ afterEach(async () => { await waitForBatchedUpdates(); }); -describe('ReportActionsList maintainVisibleContentPosition', () => { - it('enables data anchoring on first render when mounted while scrolled past the threshold', async () => { +describe('ReportActionsList visible-action threshold', () => { + it('seeds unread marker and mark-as-read state from a saved offset past the threshold', async () => { + // Given a report restored away from the newest action. await renderList(THRESHOLD + 50); - expect(isMvcpEnabled()).toBe(true); + // Then neither consumer treats the report as scrolled to the end before onScroll fires. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); }); - it('enables data anchoring at the bottom so initial hydration preserves the visible tail', async () => { + it('seeds both consumers as being at the newest action when the saved offset is zero', async () => { + // Given a report restored at its newest action. await renderList(0); - expect(isMvcpEnabled()).toBe(true); + // Then both consumers receive the bottom state immediately. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: false})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); }); - it('keeps data anchoring enabled as the user scrolls across the threshold', async () => { + it('updates both consumers when scrolling across the threshold', async () => { + // Given a report initially at the newest action. await renderList(0); - expect(isMvcpEnabled()).toBe(true); + // When the reader scrolls away from the newest action. act(() => { capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); }); - expect(isMvcpEnabled()).toBe(true); + // Then the unread marker sees the reader as away and mark-as-read pauses. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); + // When the reader returns to the newest action. act(() => { capturedListProps.onScroll?.(createScrollEvent(0)); }); - expect(isMvcpEnabled()).toBe(true); + // Then both consumers see the bottom state again. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: false})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); }); }); From ca78b551b28106208b500850fdee8ba0e371e210 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:29:36 +0200 Subject: [PATCH 020/109] refactor: share web report scroll-to-end implementation --- src/hooks/useReportScrollManager/index.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index 09ce64a5687c..9eb1ff0e1650 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -28,17 +28,7 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToEnd({animated: false}); }; - /** - * Scroll to the end of the FlatList. - */ - const scrollToEnd = () => { - const listRef = getListRef(); - if (!listRef?.current) { - return; - } - - listRef.current.scrollToEnd({animated: false}); - }; + const scrollToEnd = scrollToBottom; const scrollToOffset = (offset: number) => { const listRef = getListRef(); From 59a539746a196b4cf34d0874c4b36577e5787780 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:29:59 +0200 Subject: [PATCH 021/109] docs: note Android visible-position patch scope --- patches/react-native/details.md | 1 + 1 file changed, 1 insertion(+) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index b475827ad5f6..63f477cb5fc9 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -13,6 +13,7 @@ 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. +- Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. - Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 (closed without merging; the zero-sized LegendList anchor fix is not covered there) - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) From d2f2cc89eef435493614f6a5da202def20deda53 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:35:19 +0200 Subject: [PATCH 022/109] fix: scroll after edited action layout settles --- .../ReportActionCompose/useEditMessage.ts | 16 +++++---- tests/unit/hooks/useEditMessage.test.ts | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index a799717b18d4..691d3e68322f 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -60,17 +60,19 @@ function useEditMessage({ clearAllReportActionDrafts(); - // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. + // Wait for the editor to close and the restored action row to lay out before scrolling. + // Scrolling against the editor's old height can leave the final action below the viewport. if (!shouldScrollToLastMessage) { return; } - if (scrollToLastMessage) { - scrollToLastMessage(); - return; - } - - reportScrollManager.scrollToBottom(); + requestAnimationFrame(() => { + if (scrollToLastMessage) { + scrollToLastMessage(); + return; + } + reportScrollManager.scrollToBottom(); + }); } /** diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index 0e27b60b99f4..10b043ba23b5 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -90,17 +90,32 @@ function makeDebouncedValidator({flushResult}: {flushResult: boolean}): Debounce } describe('useEditMessage', () => { + let pendingAnimationFrame: FrameRequestCallback | undefined; + beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); + beforeEach(() => { + pendingAnimationFrame = undefined; + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + pendingAnimationFrame = callback; + return 1; + }); + }); + afterEach(async () => { await act(async () => { await Onyx.clear(); }); jest.clearAllMocks(); + jest.restoreAllMocks(); }); + function finishActionLayout() { + act(() => pendingAnimationFrame?.(0)); + } + function renderUseEditMessage(overrides?: Partial) { const report = LHNTestUtils.getFakeReport(); const reportAction = LHNTestUtils.getFakeReportAction(); @@ -154,6 +169,8 @@ describe('useEditMessage', () => { mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); }); + expect(mockScrollToBottom).not.toHaveBeenCalled(); + finishActionLayout(); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); @@ -168,10 +185,26 @@ describe('useEditMessage', () => { mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); }); + expect(scrollToLastMessage).not.toHaveBeenCalled(); + finishActionLayout(); expect(scrollToLastMessage).toHaveBeenCalledTimes(1); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); + it('scrolls to the final saved action after the editor layout has been replaced', () => { + // Given a draft on the newest action. + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + // When the edit is submitted, its optimistic update is queued before layout settles. + act(() => hook.result.current.publishDraft('Updated message')); + expect(mockEditReportComment).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).not.toHaveBeenCalled(); + + // Then scrolling runs after the restored action row gets a layout frame. + finishActionLayout(); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + }); + it('does not scroll after deleting a non-newest message draft', () => { const scrollToLastMessage = jest.fn(); const {hook} = renderUseEditMessage({shouldScrollToLastMessage: false, scrollToLastMessage}); From 08e1f69196d938bac6d625a1241681f84975468d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:35:39 +0200 Subject: [PATCH 023/109] test: type captured list load callback --- tests/ui/ReportActionsListTest.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 5148b4c108af..e1c0381e0e5e 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -531,7 +531,8 @@ describe('ReportActionsList (body)', () => { // Given a report with its action list ready to mount. mockShouldCallLegendListOnLoad = false; renderReportActionsList(); - const onLoad = mockUseReportActionsScroll.mock.results.at(-1)?.value.onLoad; + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as {onLoad?: jest.Mock} | undefined; + const onLoad = scrollHookResult?.onLoad; expect(onLoad).not.toHaveBeenCalled(); // When LegendList reports that its first layout is complete. From 84b0f1913322e67109be6f9cc01818fe6c7a091d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:46:42 +0200 Subject: [PATCH 024/109] fix: preserve Concierge tail when revealing history --- src/pages/inbox/report/ReportActionsList.tsx | 19 +++- tests/ui/ReportActionsListTest.tsx | 91 +++++++++++++++++--- 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 8353a1599357..df6ac95356c2 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -260,9 +260,15 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; - const onShowPreviousMessages = handleShowPreviousMessages; const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const shouldRestoreTailAfterHistoryRevealRef = useRef(false); + const onShowPreviousMessages = () => { + // A short Concierge session has no scrollable range. Revealing its older actions makes + // the list scrollable, so preserve the tail the reader was looking at before the reveal. + shouldRestoreTailAfterHistoryRevealRef.current = !hasScrolledOverThreshold && !hasNewerActions; + handleShowPreviousMessages(); + }; const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, @@ -329,6 +335,17 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // give it chronological data and use its normal start/end and scrolling semantics. const listData = reportActionsToRender.toReversed(); + useEffect(() => { + if (!showFullHistory || !shouldRestoreTailAfterHistoryRevealRef.current) { + return; + } + const animationFrame = requestAnimationFrame(() => { + legendListRef.current?.scrollToEnd({animated: false}); + shouldRestoreTailAfterHistoryRevealRef.current = false; + }); + return () => cancelAnimationFrame(animationFrame); + }, [showFullHistory, listData.length]); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index e1c0381e0e5e..1877cab4398f 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -141,22 +141,25 @@ const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest. // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. const mockLegendListMount = jest.fn(); const mockLegendListUnmount = jest.fn(); +const mockLegendScrollToEnd = jest.fn(); let mockShouldCallLegendListOnLoad = true; jest.mock('@legendapp/list/react-native', () => { const reactModule = jest.requireActual('react'); + const MockLegendListContent = reactModule.forwardRef<{scrollToEnd: typeof mockLegendScrollToEnd}, {onLoad?: () => void}>(({onLoad}, ref) => { + reactModule.useImperativeHandle(ref, () => ({scrollToEnd: mockLegendScrollToEnd}), []); + reactModule.useEffect(() => { + mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + } + return () => { + mockLegendListUnmount(); + }; + }, []); + return null; + }); return { - LegendList: jest.fn(({onLoad}: {onLoad?: () => void}) => { - reactModule.useEffect(() => { - mockLegendListMount(); - if (mockShouldCallLegendListOnLoad) { - onLoad?.(); - } - return () => { - mockLegendListUnmount(); - }; - }, []); - return null; - }), + LegendList: jest.fn((props: {onLoad?: () => void; ref?: React.Ref<{scrollToEnd: typeof mockLegendScrollToEnd}>}) => reactModule.createElement(MockLegendListContent, props)), }; }); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); @@ -242,6 +245,17 @@ const getRenderedReportActionsListItemProps = ( return child.props; }; +const getShowHistoryPress = (action: OnyxTypes.ReportAction, index: number): (() => void) | undefined => { + const renderedItem = getCapturedListProps()?.renderItem?.({item: action, index}); + if (!React.isValidElement<{children: React.ReactNode}>(renderedItem)) { + return undefined; + } + const historyButton = React.Children.toArray(renderedItem.props.children).find( + (child): child is React.ReactElement<{onPress: () => void}> => React.isValidElement<{onPress?: () => void}>(child) && typeof child.props.onPress === 'function', + ); + return historyButton?.props.onPress; +}; + const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); const mockUseUnreadMarker: jest.Mock = jest.requireMock('@hooks/useUnreadMarker'); @@ -741,7 +755,7 @@ describe('ReportActionsList (body)', () => { }); expect(mockAllowLegendListItemOverflow).toHaveBeenCalledTimes(1); - expect(mockAllowLegendListItemOverflow).toHaveBeenCalledWith(null, 1); + expect(mockAllowLegendListItemOverflow).toHaveBeenCalledWith(expect.objectContaining({scrollToEnd: mockLegendScrollToEnd}), 1); }); it('groups comments by layout characteristics for measurement estimates', () => { @@ -1587,6 +1601,57 @@ describe('ReportActionsList (body)', () => { expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); }); + it('keeps a short Concierge session at its visible tail when Show History reveals older actions', () => { + // Given a short session at its latest action, with history filtered out. + setupMainDMConciergeMocks(); + let isShowingFullHistory = false; + mockUseConciergeSessionState.mockImplementation(() => ({sessionStartTime: SESSION_START, showFullHistory: isShowingFullHistory, hadMessagesAtSessionStart: false})); + mockUseConciergeSessionActions.mockReturnValue({ + startSession: jest.fn(), + setShowFullHistory: (show: boolean) => { + isShowingFullHistory = show; + }, + setHadMessagesAtSessionStart: jest.fn(), + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When the reader reveals the older history. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + act(() => { + for (const callback of animationFrames) { + callback(0); + } + }); + + // Then all actions are available while the latest session stays in view. + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === 'old-user-msg')).toBe(true); + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + it('should show all actions unfiltered when user sends a message in current session', () => { setupMainDMConciergeMocks(); From a1e0d25134114f765d908ee066b6389e901219aa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:52:07 +0200 Subject: [PATCH 025/109] test: remove nonvirtualized report list benchmark --- .../perf-test/ReportActionsList.perf-test.tsx | 131 ------------------ 1 file changed, 131 deletions(-) delete mode 100644 tests/perf-test/ReportActionsList.perf-test.tsx diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx deleted file mode 100644 index a1ab104f6a2b..000000000000 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import {act, screen} from '@testing-library/react-native'; - -import OnyxListItemProvider from '@components/OnyxListItemProvider'; - -import type Navigation from '@libs/Navigation/Navigation'; -import navigationRef from '@libs/Navigation/navigationRef'; -import {setHasRadio} from '@libs/NetworkState'; - -import {ActionListContext} from '@pages/inbox/ActionListContext'; -import {ReactionListContext} from '@pages/inbox/ReactionListContext'; -import ReportActionsList from '@pages/inbox/report/ReportActionsList'; -import {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; - -import ComposeProviders from '@src/components/ComposeProviders'; -import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; - -import {NavigationContainer} from '@react-navigation/native'; -import Onyx from 'react-native-onyx'; -import {measureRenders} from 'reassure'; - -import * as ReportTestUtils from '../utils/ReportTestUtils'; -import * as TestHelper from '../utils/TestHelper'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; - -const mockedNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useRoute: () => mockedNavigate, - useIsFocused: () => true, - }; -}); - -beforeAll(() => - Onyx.init({ - keys: ONYXKEYS, - evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], - }), -); - -const mockOnLayout = jest.fn(); -// Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. -function buildActionListContextValue() { - return {scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, registerListRef: () => {}, getListRef: () => null}; -} -const actionListContextValue = buildActionListContextValue(); -const mockReactionListContextValue = { - showReactionList: () => {}, - hideReactionList: () => {}, - isActiveReportAction: () => false, -}; - -const TEST_USER_ACCOUNT_ID = 1; -const TEST_USER_LOGIN = 'test@test.com'; -const REPORT_ID = '1'; - -const signUpWithTestUser = () => { - TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); -}; - -const sortedReportActions = ReportTestUtils.getMockedSortedReportActions(500); -const reportActions: ReportActions = Object.fromEntries(sortedReportActions.map((action: ReportAction) => [action.reportActionID, action])); -const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, lastVisibleActionCreated: sortedReportActions.at(0)?.created}); - -beforeEach(async () => { - // Initialize the network key for OfflineWithFeedback - setHasRadio(true); - wrapOnyxWithWaitForBatchedUpdates(Onyx); - // Pre-seed the locale so LocaleContextProvider's mount effect is a no-op (setLocale early-returns), - // avoiding post-mount Onyx writes that would re-render outside act(). - await act(async () => { - signUpWithTestUser(); - await Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.DEFAULT); - - // Seed the report under test: the report, its 500 actions, and a settled loading state. - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); - await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, { - isLoadingInitialReportActions: false, - hasOnceLoadedReportActions: true, - isLoadingOlderReportActions: false, - hasLoadingOlderReportActionsError: false, - isLoadingNewerReportActions: false, - hasLoadingNewerReportActionsError: false, - }); - await waitForBatchedUpdates(); - }); -}); - -afterEach(async () => { - // Await the clear so its broadcasts settle in teardown instead of leaking into the next test. - await Onyx.clear(); - await waitForBatchedUpdates(); -}); - -// Mirror the production data flow (usePaginatedReportActions): the body reads REPORT_ACTIONS from Onyx and -// sorts inside a useOnyx selector, then runs it through the real continuous-chain pagination and the -// visibility filter. -function ReportActionsListWrapper() { - return ( - - - - - - - - - - ); -} - -// LegendList's behavior mock eagerly renders the supplied data, while the previous FlashList test used -// its virtualized implementation. Start a renderer-specific baseline instead of comparing those harnesses. -test('[ReportActionsList] should render LegendList with 500 reportActions stored', async () => { - const scenario = async () => { - await screen.findByTestId('report-actions-list'); - }; - await waitForBatchedUpdates(); - await measureRenders(, {scenario}); -}); From e37edb4ddb51e0bf29c06de403c80f00e7bfc512 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 14:56:57 +0200 Subject: [PATCH 026/109] test: mock edited-action draft clearing --- tests/unit/hooks/useEditMessage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index 10b043ba23b5..01e2081db571 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -21,7 +21,7 @@ jest.mock('@libs/actions/Report', () => { return { ...actual, editReportComment: jest.fn(), - clearReportActionDrafts: jest.fn(), + clearAllReportActionDrafts: jest.fn(), }; }); From e904b9988930140ad2678688ed965bca0f0ff825 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 15:53:10 +0200 Subject: [PATCH 027/109] test: wait for edited action scroll frame --- tests/ui/ReportActionItemMessageEditTest.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index e7468c15412d..2d92a8f4d0ac 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -1,4 +1,4 @@ -import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -219,14 +219,17 @@ describe('ReportActionCompose Integration Tests', () => { expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-width'); }); - it('should use the list-specific scroll after saving the newest message', () => { + it('should use the list-specific scroll after saving the newest message', async () => { + // Given the newest message is being edited in a report list const scrollToNewestAction = jest.fn(); renderReportActionItemMessageEdit(undefined, {index: 9, isNewest: true, scrollToNewestAction}); + // When the edit is saved and the restored message row has time to lay out fireEvent.changeText(screen.getByTestId('composer'), 'Edited message'); fireEvent.press(screen.getByLabelText('common.saveChanges')); - expect(scrollToNewestAction).toHaveBeenCalledTimes(1); + // Then the list scrolls to the newest action after the scheduled frame + await waitFor(() => expect(scrollToNewestAction).toHaveBeenCalledTimes(1)); }); it('should not scroll after saving a non-newest message at index zero', () => { From 40f77197ba69984ff495e641c012e18ec296a7f9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 16:18:45 +0200 Subject: [PATCH 028/109] chore: update LegendList to 3.4.0 --- 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 b2c6deb5b564..f84e7ac909ce 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.10", + "@legendapp/list": "^3.4.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -12109,9 +12109,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.3.10", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.10.tgz", - "integrity": "sha512-S8cwV11oJJD2m47JoRnCmlgbUrnY/f48TPL1zl1C3wwyrvru53Zf1NtYFlMtAU6lUKh/cU9RIffLI7unbf3kmg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.4.0.tgz", + "integrity": "sha512-Tlx7Uf9pRP/JbrekuWdKf1+42C9MTfl1wGkP8Lvmg2E2T9rCWQphYOs8aOkcvgbAkJ2S+WR+RTXHS2+6U+nROw==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index da86ff47f9b6..0221d715f35f 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.10", + "@legendapp/list": "^3.4.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", From a68ee3c024232addfd13a5054f4e1376753a5c7e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Sep 2026 16:30:08 +0200 Subject: [PATCH 029/109] fix: cover warm report actions during initial layout --- src/pages/inbox/report/ReportActionsList.tsx | 6 ++---- tests/ui/ReportActionsListTest.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index df6ac95356c2..b26181da4258 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -403,10 +403,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }); const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); - // A warm report already has its complete action page. Only an initial visit needs a cover - // while the first page finishes loading and LegendList positions it. - const [shouldCoverInitialViewport] = useState(!hasOnceLoadedReportActions); - const shouldShowInitialViewportSkeleton = !isOffline && shouldCoverInitialViewport && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + // Cover every initial LegendList layout, including cached chats, until the rows are positioned. + const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { onViewableItemsChanged(info); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 1877cab4398f..32580971cb8c 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -556,7 +556,7 @@ describe('ReportActionsList (body)', () => { expect(onLoad).toHaveBeenCalledTimes(1); }); - it('shows a previously hydrated report without replaying the loading cover', () => { + it('covers a previously hydrated report until LegendList finishes its initial layout', () => { // Given a report that was already loaded before this list mounted mockUseNetwork.mockReturnValue({isOffline: false}); mockShouldCallLegendListOnLoad = false; @@ -564,8 +564,11 @@ describe('ReportActionsList (body)', () => { // When the user opens the report again renderReportActionsList(); - // Then cached actions stay visible while LegendList completes its layout + // Then the cover hides LegendList's empty initial viewport until its actions are ready expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => getCapturedListProps()?.onLoad?.()); expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); }); From e117ffcbe639bd68a8ff3c49ebf80c84c92afb7f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 25 Sep 2026 16:27:06 +0200 Subject: [PATCH 030/109] fix: render Concierge notification banner before chat scrolls to end --- .../notificationPermission/index.native.ts | 1 + .../Notification/notificationPermission/index.ts | 13 +++++++++---- .../Notification/notificationPermission/types.ts | 1 + .../useShouldShowEnableNotificationsBanner.ts | 3 ++- .../useShouldShowEnableNotificationsBanner.test.ts | 12 ++++++++++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/libs/Notification/notificationPermission/index.native.ts b/src/libs/Notification/notificationPermission/index.native.ts index 29123ad37089..bd0fe625309e 100644 --- a/src/libs/Notification/notificationPermission/index.native.ts +++ b/src/libs/Notification/notificationPermission/index.native.ts @@ -1,6 +1,7 @@ import type NotificationPermissionModule from './types'; const NotificationPermissionNative: NotificationPermissionModule = { + getStatusSync: () => 'denied', getStatus: () => Promise.resolve('denied'), request: () => Promise.resolve('denied'), }; diff --git a/src/libs/Notification/notificationPermission/index.ts b/src/libs/Notification/notificationPermission/index.ts index 62f333a376f4..333fecf4bf54 100644 --- a/src/libs/Notification/notificationPermission/index.ts +++ b/src/libs/Notification/notificationPermission/index.ts @@ -10,12 +10,17 @@ function toStatus(permission: NotificationPermission): NotificationPermissionSta return 'default'; } +function getStatusSync(): NotificationPermissionStatus { + if (typeof window === 'undefined' || !window.Notification) { + return 'denied'; + } + return toStatus(Notification.permission); +} + const NotificationPermission: NotificationPermissionModule = { + getStatusSync, getStatus(): Promise { - if (typeof window === 'undefined' || !window.Notification) { - return Promise.resolve('denied'); - } - return Promise.resolve(toStatus(Notification.permission)); + return Promise.resolve(getStatusSync()); }, request(): Promise { diff --git a/src/libs/Notification/notificationPermission/types.ts b/src/libs/Notification/notificationPermission/types.ts index 9ffca38417ae..b6c834f2ed36 100644 --- a/src/libs/Notification/notificationPermission/types.ts +++ b/src/libs/Notification/notificationPermission/types.ts @@ -1,6 +1,7 @@ type NotificationPermissionStatus = 'granted' | 'denied' | 'default'; type NotificationPermissionModule = { + getStatusSync: () => NotificationPermissionStatus; getStatus: () => Promise; request: () => Promise; }; diff --git a/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts b/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts index 82a875706485..c60c604a7497 100644 --- a/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts +++ b/src/pages/inbox/report/useShouldShowEnableNotificationsBanner.ts @@ -14,7 +14,8 @@ import {useEffect, useState} from 'react'; function useShouldShowEnableNotificationsBanner(report: OnyxEntry): boolean { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [hasDismissed] = useOnyx(ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER); - const [permissionStatus, setPermissionStatus] = useState(); + // Reserve the banner's space before the report list makes its initial scroll-to-end measurement. + const [permissionStatus, setPermissionStatus] = useState(() => NotificationPermission.getStatusSync()); const isConcierge = isConciergeChatReport(report, conciergeReportID); diff --git a/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts b/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts index 38e5c20713bb..4263e110fcf5 100644 --- a/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts +++ b/tests/unit/hooks/useShouldShowEnableNotificationsBanner.test.ts @@ -20,6 +20,7 @@ let mockCurrentPermission: NotificationPermissionStatus = 'default'; jest.mock('@libs/Notification/notificationPermission', () => ({ __esModule: true, default: { + getStatusSync: () => mockCurrentPermission, getStatus: () => Promise.resolve(mockCurrentPermission), request: () => Promise.resolve(mockCurrentPermission), }, @@ -69,4 +70,15 @@ describe('useShouldShowEnableNotificationsBanner', () => { const {result} = renderHook(() => useShouldShowEnableNotificationsBanner(conciergeReport)); await waitFor(() => expect(result.current).toBe(true)); }); + + it('shows the Concierge banner on the initial render so the chat opens with its final viewport height', () => { + // Given notification permission has not been decided and the Concierge report is already known. + mockCurrentPermission = 'default'; + + // When Concierge opens, the list and footer measure their initial layout together. + const {result} = renderHook(() => useShouldShowEnableNotificationsBanner(conciergeReport)); + + // Then the banner must occupy space before the list positions itself at the latest message. + expect(result.current).toBe(true); + }); }); From 4adbdaabeb64b5d8958d33c46504103a6a59de30 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 25 Sep 2026 16:37:52 +0200 Subject: [PATCH 031/109] fix: keep latest chat action visible after list measurement --- src/pages/inbox/report/ReportActionsList.tsx | 66 ++++++++++++++- tests/ui/ReportActionsListTest.tsx | 88 +++++++++++++++++++- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index b26181da4258..bc725e2c709d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -91,6 +91,7 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; const PAGINATION_THRESHOLD = 0.75; +const MAINTAIN_SCROLL_AT_END_THRESHOLD = 0.01; const REPORT_ACTIONS_DRAW_DISTANCE = 1500; const REPORT_ACTION_COMMENT_SIZE = { @@ -405,6 +406,39 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); // Cover every initial LegendList layout, including cached chats, until the rows are positioned. const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + const readyEndListIDRef = useRef(undefined); + const shouldFollowEndOnResizeRef = useRef(false); + const lastScrollMetricsRef = useRef<{offset: number; contentHeight: number; viewportHeight: number} | undefined>(undefined); + const endCorrectionFrameRef = useRef(undefined); + + const scheduleEndCorrection = () => { + if ( + readyEndListIDRef.current !== listID || + !shouldFollowEndOnResizeRef.current || + hasNewerActions || + initialScrollIndex !== undefined || + endCorrectionFrameRef.current !== undefined + ) { + return; + } + endCorrectionFrameRef.current = requestAnimationFrame(() => { + endCorrectionFrameRef.current = undefined; + if (readyEndListIDRef.current === listID && shouldFollowEndOnResizeRef.current) { + legendListRef.current?.scrollToEnd({animated: false}); + } + }); + }; + + useEffect( + () => () => { + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; + }, + [listID], + ); const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { onViewableItemsChanged(info); @@ -421,6 +455,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setLoadedInitialViewportListID(listID); }; + const handleListReady = () => { + readyEndListIDRef.current = listID; + shouldFollowEndOnResizeRef.current = initialScrollIndex === undefined && !hasNewerActions; + lastScrollMetricsRef.current = undefined; + // The final row can grow after LegendList's initial end estimate (for example, Concierge feedback). + scheduleEndCorrection(); + }; + const loadOlderChatsOnStartReached = () => { if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { return; @@ -434,6 +476,21 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct 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; + const previousMetrics = lastScrollMetricsRef.current; + if (readyEndListIDRef.current === listID && !hasNewerActions) { + if (distanceFromBottom <= layoutMeasurement.height * MAINTAIN_SCROLL_AT_END_THRESHOLD) { + shouldFollowEndOnResizeRef.current = true; + } else if ( + (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || + (previousMetrics && + previousMetrics.contentHeight === contentSize.height && + previousMetrics.viewportHeight === layoutMeasurement.height && + endCorrectionFrameRef.current === undefined) + ) { + shouldFollowEndOnResizeRef.current = false; + } + } + lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; if (isNearStart) { loadOlderChatsOnStartReached(); @@ -585,6 +642,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct */ const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { onLayout?.(event); + scheduleEndCorrection(); if (didLayout.current) { return; } @@ -657,10 +715,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // Only follow the real latest page. Older/linked windows must retain their visible anchor. maintainScrollAtEnd={!hasNewerActions && {animated: false}} // Leave the end-follow region as soon as the user starts reading older messages. - maintainScrollAtEndThreshold={0.01} + maintainScrollAtEndThreshold={MAINTAIN_SCROLL_AT_END_THRESHOLD} maintainVisibleContentPosition onLoad={handleListLoad} - onContentSizeChange={() => trackVerticalScrolling(undefined)} + onReady={handleListReady} + onContentSizeChange={() => { + trackVerticalScrolling(undefined); + scheduleEndCorrection(); + }} /> {shouldShowInitialViewportSkeleton && ( { const reactModule = jest.requireActual('react'); - const MockLegendListContent = reactModule.forwardRef<{scrollToEnd: typeof mockLegendScrollToEnd}, {onLoad?: () => void}>(({onLoad}, ref) => { + const MockLegendListContent = reactModule.forwardRef<{scrollToEnd: typeof mockLegendScrollToEnd}, {onLoad?: () => void; onReady?: () => void}>(({onLoad, onReady}, ref) => { reactModule.useImperativeHandle(ref, () => ({scrollToEnd: mockLegendScrollToEnd}), []); reactModule.useEffect(() => { mockLegendListMount(); if (mockShouldCallLegendListOnLoad) { onLoad?.(); + onReady?.(); } return () => { mockLegendListUnmount(); @@ -159,7 +160,9 @@ jest.mock('@legendapp/list/react-native', () => { return null; }); return { - LegendList: jest.fn((props: {onLoad?: () => void; ref?: React.Ref<{scrollToEnd: typeof mockLegendScrollToEnd}>}) => reactModule.createElement(MockLegendListContent, props)), + LegendList: jest.fn((props: {onLoad?: () => void; onReady?: () => void; ref?: React.Ref<{scrollToEnd: typeof mockLegendScrollToEnd}>}) => + reactModule.createElement(MockLegendListContent, props), + ), }; }); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); @@ -201,6 +204,7 @@ type MockLegendListProps = { maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; onLoad?: () => void; + onReady?: () => void; onStartReachedThreshold?: number; onContentSizeChange?: (width: number, height: number) => void; onViewableItemsChanged?: (info: OnViewableItemsChangedInfo) => void; @@ -440,6 +444,84 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); }); + it('corrects the initial end position after the latest row has been measured', async () => { + // Given a report opens at its latest action and the virtualized list finishes its initial placement. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + // When the last row is taller than the estimate, its feedback controls must not remain below the viewport. + // Then the list makes one final end scroll after the layout is ready. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('keeps the latest message visible when its measured height grows after opening', async () => { + // Given the chat has opened at the end with a measured viewport. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + mockLegendScrollToEnd.mockClear(); + const listProps = getCapturedListProps(); + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When a late feedback row makes the content taller without a user scroll. + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1025, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // Then the last 25 pixels are brought into view. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('does not pull the chat back down when the reader has scrolled away', async () => { + // Given the chat opened at the end and the reader scrolled upward. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + mockLegendScrollToEnd.mockClear(); + const listProps = getCapturedListProps(); + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 400}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // When content grows, the reader's position stays where they left it. + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + }); + it('does not duplicate the composer spacing inside the chronological list', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); From c92613d5d5f5cf37cded40cf918b637125376089 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 25 Sep 2026 17:03:07 +0200 Subject: [PATCH 032/109] fix: preserve Concierge scroll position when showing history --- src/hooks/useReportActionsListModel.ts | 2 + src/pages/inbox/report/ReportActionsList.tsx | 33 +++-- tests/ui/ReportActionsListTest.tsx | 142 +++++++++++++++++++ 3 files changed, 169 insertions(+), 8 deletions(-) diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index d8dce8b79da0..4b7331487bf1 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -154,6 +154,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea report, hasOnceLoadedReportActions, isInitialReportLoadPending, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, hasOlderActions, hasNewerActions, oldestReportActionID: currentReportOldestActionID, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index bc725e2c709d..a3ab06fe21e3 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -167,6 +167,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct report, hasOnceLoadedReportActions, isInitialReportLoadPending, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, hasOlderActions, hasNewerActions, oldestReportActionID, @@ -264,10 +266,18 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const shouldRestoreTailAfterHistoryRevealRef = useRef(false); + const oldestLoadedActionID = allReportActionIDs.at(-1); + const oldestLoadedActionIDAtHistoryRevealRef = useRef(undefined); + const distanceFromBottomRef = useRef(getScrollOffset()); + const shouldFollowEndOnResizeRef = useRef(false); const onShowPreviousMessages = () => { - // A short Concierge session has no scrollable range. Revealing its older actions makes - // the list scrollable, so preserve the tail the reader was looking at before the reveal. - shouldRestoreTailAfterHistoryRevealRef.current = !hasScrolledOverThreshold && !hasNewerActions; + // Revealing older actions can make a short session scrollable before the requested history page arrives. + // Follow the tail only when the reader was actually there; the action-visible threshold is much wider. + shouldRestoreTailAfterHistoryRevealRef.current = distanceFromBottomRef.current <= 1 && !hasNewerActions; + oldestLoadedActionIDAtHistoryRevealRef.current = shouldRestoreTailAfterHistoryRevealRef.current && hasOlderActions ? oldestLoadedActionID : undefined; + if (shouldRestoreTailAfterHistoryRevealRef.current) { + shouldFollowEndOnResizeRef.current = true; + } handleShowPreviousMessages(); }; @@ -337,15 +347,22 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const listData = reportActionsToRender.toReversed(); useEffect(() => { - if (!showFullHistory || !shouldRestoreTailAfterHistoryRevealRef.current) { + if (!showFullHistory) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + return; + } + if (!shouldRestoreTailAfterHistoryRevealRef.current) { return; } const animationFrame = requestAnimationFrame(() => { legendListRef.current?.scrollToEnd({animated: false}); - shouldRestoreTailAfterHistoryRevealRef.current = false; + const hasReceivedHistoryPage = oldestLoadedActionID !== oldestLoadedActionIDAtHistoryRevealRef.current || !hasOlderActions; + if (!oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError))) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + } }); return () => cancelAnimationFrame(animationFrame); - }, [showFullHistory, listData.length]); + }, [showFullHistory, listData.length, oldestLoadedActionID, hasOlderActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; @@ -407,7 +424,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // Cover every initial LegendList layout, including cached chats, until the rows are positioned. const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); const readyEndListIDRef = useRef(undefined); - const shouldFollowEndOnResizeRef = useRef(false); const lastScrollMetricsRef = useRef<{offset: number; contentHeight: number; viewportHeight: number} | undefined>(undefined); const endCorrectionFrameRef = useRef(undefined); @@ -475,10 +491,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + distanceFromBottomRef.current = distanceFromBottom; const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; const previousMetrics = lastScrollMetricsRef.current; if (readyEndListIDRef.current === listID && !hasNewerActions) { - if (distanceFromBottom <= layoutMeasurement.height * MAINTAIN_SCROLL_AT_END_THRESHOLD) { + if (shouldRestoreTailAfterHistoryRevealRef.current || distanceFromBottom <= layoutMeasurement.height * MAINTAIN_SCROLL_AT_END_THRESHOLD) { shouldFollowEndOnResizeRef.current = true; } else if ( (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 6517a913962d..2042e3486785 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1737,6 +1737,148 @@ describe('ReportActionsList (body)', () => { } }); + it('keeps the latest Concierge messages visible when an older history page arrives after Show History', () => { + // Given a Concierge session with more history to load after the initially revealed actions. + setupMainDMConciergeMocks(); + let isShowingFullHistory = false; + mockUseConciergeSessionState.mockImplementation(() => ({sessionStartTime: SESSION_START, showFullHistory: isShowingFullHistory, hadMessagesAtSessionStart: false})); + mockUseConciergeSessionActions.mockReturnValue({ + startSession: jest.fn(), + setShowFullHistory: (show: boolean) => { + isShowingFullHistory = show; + }, + setHadMessagesAtSessionStart: jest.fn(), + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: true}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + const flushAnimationFrames = () => { + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + }; + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When Show History reveals the loaded actions and an older page arrives afterward. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + flushAnimationFrames(); + mockLegendScrollToEnd.mockClear(); + + const oldUserAction = oldReportActions.at(1); + if (!oldUserAction) { + throw new Error('Expected the older Concierge action'); + } + const olderAction = {...oldUserAction, reportActionID: 'older-user-msg', created: '2022-06-15 10:00:00.000'}; + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: [...oldReportActions, olderAction], hasOlderActions: false}); + view.rerender( + , + ); + flushAnimationFrames(); + + // Then the added page remains available without moving the reader away from the latest messages. + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === 'older-user-msg')).toBe(true); + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + + it('does not pull a slightly scrolled Concierge chat to the bottom when Show History is pressed', () => { + // Given the reader is above the latest message but still within the generic action-visible threshold. + setupMainDMConciergeMocks(); + let isShowingFullHistory = false; + mockUseConciergeSessionState.mockImplementation(() => ({sessionStartTime: SESSION_START, showFullHistory: isShowingFullHistory, hadMessagesAtSessionStart: false})); + mockUseConciergeSessionActions.mockReturnValue({ + startSession: jest.fn(), + setShowFullHistory: (show: boolean) => { + isShowingFullHistory = show; + }, + setHadMessagesAtSessionStart: jest.fn(), + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + mockLegendScrollToEnd.mockClear(); + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 400}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When the reader reveals older history. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + + // Then the reader's offset is left to LegendList's visible-content preservation. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + it('should show all actions unfiltered when user sends a message in current session', () => { setupMainDMConciergeMocks(); From 7096033845a3aa7a91a197084bb29849d22c9205 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Sep 2026 13:40:04 +0200 Subject: [PATCH 033/109] fix: preserve unread message alignment at the latest action --- src/hooks/useReportActionsScroll.ts | 9 ++++++--- tests/unit/useReportActionsScrollTest.tsx | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index bfe62b432f7f..cb52d7f01150 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -343,10 +343,13 @@ function useReportActionsScroll({ let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; if (targetIndex >= 0) { initialScrollIndex = targetIndex; - // The final action can be taller than the viewport. Aligning its top can leave the - // highlighted content below the screen; at the tail, show the action's end instead. + // A linked final action can be taller than the viewport, so show its highlighted end. + // Unread actions still start at the New marker. Omit viewOffset for bottom alignment so + // LegendList includes the footer (for example, Concierge's thinking indicator). initialScrollIndexParams = - targetIndex === renderedVisibleReportActions.length - 1 ? {viewPosition: 1, viewOffset: 0} : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + initialScrollKey === linkedReportActionID && targetIndex === renderedVisibleReportActions.length - 1 + ? {viewPosition: 1} + : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { initialScrollIndex = 0; } diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index f73c3d40d140..5cd100198bda 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -285,7 +285,7 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); expect(result.current.initialScrollIndex).toBe(0); - expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 1, viewOffset: 0}); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 1}); }); it('top-aligns a linked report action when later actions follow it', async () => { @@ -302,14 +302,18 @@ describe('useReportActionsScroll', () => { }); it('positions an unread marker at chronological index zero', async () => { + // Given the newest action is unread and can be taller than the viewport. const unreadAction = makeAction(UNREAD_ACTION_ID); + // When opening the report at its unread boundary. const {result} = await renderScroll({ unreadMarkerReportActionID: UNREAD_ACTION_ID, sortedVisibleReportActions: [unreadAction], renderedVisibleReportActions: [unreadAction], }); + // Then the start of the action and its New marker remain visible. expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); it('suppresses the initial scroll key for an aligned-to-top CREATED anchor action', async () => { From c0751dd1b9880cd6d5b1fc0edb21c91d9f47c1b3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Sep 2026 13:41:33 +0200 Subject: [PATCH 034/109] fix: cancel report end corrections when the reader scrolls --- src/pages/inbox/report/ReportActionsList.tsx | 27 ++++++-- tests/ui/ReportActionsListTest.tsx | 69 +++++++++++++++++--- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a3ab06fe21e3..60d4d747833d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -355,6 +355,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return; } const animationFrame = requestAnimationFrame(() => { + if (!shouldRestoreTailAfterHistoryRevealRef.current || !shouldFollowEndOnResizeRef.current) { + return; + } legendListRef.current?.scrollToEnd({animated: false}); const hasReceivedHistoryPage = oldestLoadedActionID !== oldestLoadedActionIDAtHistoryRevealRef.current || !hasOlderActions; if (!oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError))) { @@ -426,6 +429,18 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const readyEndListIDRef = useRef(undefined); const lastScrollMetricsRef = useRef<{offset: number; contentHeight: number; viewportHeight: number} | undefined>(undefined); const endCorrectionFrameRef = useRef(undefined); + const userScrolledListIDRef = useRef(undefined); + + const stopFollowingEnd = () => { + userScrolledListIDRef.current = listID; + shouldFollowEndOnResizeRef.current = false; + shouldRestoreTailAfterHistoryRevealRef.current = false; + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; + }; const scheduleEndCorrection = () => { if ( @@ -473,7 +488,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const handleListReady = () => { readyEndListIDRef.current = listID; - shouldFollowEndOnResizeRef.current = initialScrollIndex === undefined && !hasNewerActions; + shouldFollowEndOnResizeRef.current = initialScrollIndex === undefined && !hasNewerActions && userScrolledListIDRef.current !== listID; lastScrollMetricsRef.current = undefined; // The final row can grow after LegendList's initial end estimate (for example, Concierge feedback). scheduleEndCorrection(); @@ -495,7 +510,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; const previousMetrics = lastScrollMetricsRef.current; if (readyEndListIDRef.current === listID && !hasNewerActions) { - if (shouldRestoreTailAfterHistoryRevealRef.current || distanceFromBottom <= layoutMeasurement.height * MAINTAIN_SCROLL_AT_END_THRESHOLD) { + if (distanceFromBottom <= layoutMeasurement.height * MAINTAIN_SCROLL_AT_END_THRESHOLD) { shouldFollowEndOnResizeRef.current = true; } else if ( (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || @@ -504,7 +519,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct previousMetrics.viewportHeight === layoutMeasurement.height && endCorrectionFrameRef.current === undefined) ) { - shouldFollowEndOnResizeRef.current = false; + // A pending history request must not reclaim the end after the reader has moved away. + stopFollowingEnd(); } } lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; @@ -720,6 +736,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyboardShouldPersistTaps="handled" onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} + onScrollBeginDrag={stopFollowingEnd} onStartReached={loadOlderChatsOnStartReached} onStartReachedThreshold={PAGINATION_THRESHOLD} onViewableItemsChanged={updateVisibleItemOverflow} @@ -729,8 +746,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} alignItemsAtEnd={!shouldBeAlignedToTop} - // Only follow the real latest page. Older/linked windows must retain their visible anchor. - maintainScrollAtEnd={!hasNewerActions && {animated: false}} + // Follow new actions on the latest page. Layout changes use the cancellable correction above. + maintainScrollAtEnd={!hasNewerActions && {animated: false, on: {dataChange: true}}} // Leave the end-follow region as soon as the user starts reading older messages. maintainScrollAtEndThreshold={MAINTAIN_SCROLL_AT_END_THRESHOLD} maintainVisibleContentPosition diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 2042e3486785..8ac9fb16fd48 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -200,11 +200,12 @@ type MockLegendListProps = { extraData?: unknown; getItemType?: (item: OnyxTypes.ReportAction) => string; initialScrollAtEnd?: boolean; - maintainScrollAtEnd?: {animated: boolean} | false; + maintainScrollAtEnd?: {animated: boolean; on: {dataChange: boolean}} | false; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; onLoad?: () => void; onReady?: () => void; + onScrollBeginDrag?: () => void; onStartReachedThreshold?: number; onContentSizeChange?: (width: number, height: number) => void; onViewableItemsChanged?: (info: OnViewableItemsChangedInfo) => void; @@ -435,11 +436,11 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); - it('delegates end following and size corrections to LegendList without a full-viewport threshold', () => { + it('delegates new-action following to LegendList without a full-viewport threshold', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); - expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {dataChange: true}}); expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(0.01); expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); }); @@ -522,6 +523,35 @@ describe('ReportActionsList (body)', () => { expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); }); + it('cancels a queued end correction as soon as the reader starts dragging', async () => { + // Given removing the latest message has queued a layout correction at the end. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + try { + const listProps = getCapturedListProps(); + // When the user touches the list before the next scroll metrics arrive. + act(() => { + listProps?.onContentSizeChange?.(300, 900); + listProps?.onScrollBeginDrag?.(); + for (const callback of animationFrames) { + callback(0); + } + }); + + // Then even a callback already dispatched by the frame scheduler respects the gesture. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + it('does not duplicate the composer spacing inside the chronological list', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); @@ -549,7 +579,7 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); expect(getCapturedListProps()?.alignItemsAtEnd).toBe(true); - expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {dataChange: true}}); }); it('does not follow the end of a page that still has newer actions to load', () => { @@ -588,7 +618,7 @@ describe('ReportActionsList (body)', () => { expect(mockLegendListMount).toHaveBeenCalledTimes(2); expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); - expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {dataChange: true}}); }); it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { @@ -1737,7 +1767,7 @@ describe('ReportActionsList (body)', () => { } }); - it('keeps the latest Concierge messages visible when an older history page arrives after Show History', () => { + it.each([false, true])('only follows a delayed Show History page while the reader stays at the end (scrolls away: %s)', (scrollsAway) => { // Given a Concierge session with more history to load after the initially revealed actions. setupMainDMConciergeMocks(); let isShowingFullHistory = false; @@ -1784,6 +1814,25 @@ describe('ReportActionsList (body)', () => { flushAnimationFrames(); mockLegendScrollToEnd.mockClear(); + if (scrollsAway) { + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 200}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + } + const oldUserAction = oldReportActions.at(1); if (!oldUserAction) { throw new Error('Expected the older Concierge action'); @@ -1799,9 +1848,13 @@ describe('ReportActionsList (body)', () => { ); flushAnimationFrames(); - // Then the added page remains available without moving the reader away from the latest messages. + // Then loading the page respects the reader's choice to stay at the end or read history. expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === 'older-user-msg')).toBe(true); - expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + if (scrollsAway) { + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } else { + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } } finally { requestAnimationFrameSpy.mockRestore(); } From 0cb9d7be3841cf870726b71d87ec0302534a8e5c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Sep 2026 13:43:55 +0200 Subject: [PATCH 035/109] fix: keep report scroll events flowing on web --- src/pages/inbox/report/ReportActionsList.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 60d4d747833d..5f6e1d29adaf 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,4 +1,5 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; +import type {ActionSheetAwareScrollViewProps} from '@components/ActionSheetAwareScrollView/types'; import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; @@ -726,7 +727,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct drawDistance={REPORT_ACTIONS_DRAW_DISTANCE} recycleItems experimental_hideItemsUntilMeasured - renderScrollComponent={renderActionSheetAwareScrollView} + renderScrollComponent={renderReportActionsScrollView} contentContainerStyle={[styles.chatContentScrollView, styles.pb0]} onEndReached={loadNewerChatsAfterTransitions} onEndReachedThreshold={PAGINATION_THRESHOLD} @@ -792,3 +793,9 @@ function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsLis } export default ReportActionsList; + +function renderReportActionsScrollView(props: ActionSheetAwareScrollViewProps) { + // LegendList passes 0 to custom scroll views. On web that only emits the first and last event, + // allowing the hover-suppression timer to expire during a continuous scroll. + return renderActionSheetAwareScrollView({...props, scrollEventThrottle: 16}); +} From 0f53485b08c51ceab379a46b7c99461eeab25e4f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Sep 2026 13:53:33 +0200 Subject: [PATCH 036/109] fix: keep chat skeleton visible until initial scrolling settles --- src/pages/inbox/report/ReportActionsList.tsx | 7 ++----- tests/ui/ReportActionsListTest.tsx | 17 ++++++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 5f6e1d29adaf..36663bb2e0dc 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -482,12 +482,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct } }; - const handleListLoad = () => { + const handleListReady = () => { + // onLoad only means rows rendered; keep their estimated positions covered until initial scrolling settles. onLoad(); setLoadedInitialViewportListID(listID); - }; - - const handleListReady = () => { readyEndListIDRef.current = listID; shouldFollowEndOnResizeRef.current = initialScrollIndex === undefined && !hasNewerActions && userScrolledListIDRef.current !== listID; lastScrollMetricsRef.current = undefined; @@ -752,7 +750,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // Leave the end-follow region as soon as the user starts reading older messages. maintainScrollAtEndThreshold={MAINTAIN_SCROLL_AT_END_THRESHOLD} maintainVisibleContentPosition - onLoad={handleListLoad} onReady={handleListReady} onContentSizeChange={() => { trackVerticalScrolling(undefined); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 8ac9fb16fd48..58a36fd1b4a1 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -631,7 +631,7 @@ describe('ReportActionsList (body)', () => { expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); act(() => { - getCapturedListProps()?.onLoad?.(); + getCapturedListProps()?.onReady?.(); }); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); @@ -648,12 +648,12 @@ describe('ReportActionsList (body)', () => { expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); act(() => { - getCapturedListProps()?.onLoad?.(); + getCapturedListProps()?.onReady?.(); }); expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); }); - it('forwards the list load event so initial scroll tracking can resume', () => { + it('resumes initial scroll tracking after layout and scrolling are ready', () => { // Given a report with its action list ready to mount. mockShouldCallLegendListOnLoad = false; renderReportActionsList(); @@ -661,8 +661,8 @@ describe('ReportActionsList (body)', () => { const onLoad = scrollHookResult?.onLoad; expect(onLoad).not.toHaveBeenCalled(); - // When LegendList reports that its first layout is complete. - act(() => getCapturedListProps()?.onLoad?.()); + // When LegendList reports that layout and initial scrolling are complete. + act(() => getCapturedListProps()?.onReady?.()); // Then the scroll hook receives that event to finish initial positioning. expect(onLoad).toHaveBeenCalledTimes(1); @@ -681,6 +681,9 @@ describe('ReportActionsList (body)', () => { expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); act(() => getCapturedListProps()?.onLoad?.()); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => getCapturedListProps()?.onReady?.()); expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); }); @@ -694,7 +697,7 @@ describe('ReportActionsList (body)', () => { expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); act(() => { - getCapturedListProps()?.onLoad?.(); + getCapturedListProps()?.onReady?.(); }); expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); @@ -709,7 +712,7 @@ describe('ReportActionsList (body)', () => { const view = renderReportActionsList(); act(() => { - getCapturedListProps()?.onLoad?.(); + getCapturedListProps()?.onReady?.(); }); expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); From b611f3838000c157310c054f46b2d32bc155dbee Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Sep 2026 13:59:54 +0200 Subject: [PATCH 037/109] fix: resume end following after scrolling past a linked message --- src/pages/inbox/report/ReportActionsList.tsx | 8 +---- tests/ui/ReportActionsListTest.tsx | 31 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 36663bb2e0dc..fefda07db672 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -444,13 +444,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }; const scheduleEndCorrection = () => { - if ( - readyEndListIDRef.current !== listID || - !shouldFollowEndOnResizeRef.current || - hasNewerActions || - initialScrollIndex !== undefined || - endCorrectionFrameRef.current !== undefined - ) { + if (readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined) { return; } endCorrectionFrameRef.current = requestAnimationFrame(() => { diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 58a36fd1b4a1..ebf8b374cbf2 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -11,6 +11,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; +import type useReportActionsScroll from '@hooks/useReportActionsScroll'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelState from '@hooks/useSidePanelState'; @@ -523,6 +524,36 @@ describe('ReportActionsList (body)', () => { expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); }); + it('resumes resize following after scrolling from a linked action to the end', async () => { + // Given the chat opens at a linked action, which must keep its initial position. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll() as ReturnType; + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 0}); + try { + renderReportActionsList(); + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + + // When the reader reaches the end and the latest row grows afterward. + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // Then the latest content remains visible even though the route still contains a linked action. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + } + }); + it('cancels a queued end correction as soon as the reader starts dragging', async () => { // Given removing the latest message has queued a layout correction at the end. mockUseNetwork.mockReturnValue({isOffline: false}); From 779eaacf57bdbe2b3395f9cad9459669da761fd6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 1 Oct 2026 15:47:56 +0200 Subject: [PATCH 038/109] fix: preserve chat content when clearing linked messages Clearing the linked-action route after sending remounted the report content and LegendList, briefly restoring the loading skeleton and resetting composer state. Keep the report content mounted while resetting only the link checks, and remount the list only when opening a linked action or completing initial hydration. --- src/pages/inbox/LinkedActionNotFoundGuard.tsx | 23 +++-- src/pages/inbox/report/ReportActionsList.tsx | 12 ++- .../LinkedActionNotFoundGuardTest.tsx | 88 +++++++++++++++---- tests/ui/ReportActionsListTest.tsx | 63 +++++++++++++ 4 files changed, 155 insertions(+), 31 deletions(-) diff --git a/src/pages/inbox/LinkedActionNotFoundGuard.tsx b/src/pages/inbox/LinkedActionNotFoundGuard.tsx index d77c311a4068..697064fe7fa7 100644 --- a/src/pages/inbox/LinkedActionNotFoundGuard.tsx +++ b/src/pages/inbox/LinkedActionNotFoundGuard.tsx @@ -30,26 +30,25 @@ function LinkedActionNotFoundGuard({children}: LinkedActionNotFoundGuardProps) { const routeParams = route.params as {reportActionID?: string} | undefined; const reportActionIDFromRoute = routeParams?.reportActionID; - if (!reportActionIDFromRoute) { - return children; - } - return ( - + <> + {/* Reset the link checks without remounting the report list and composer. */} + {!!reportActionIDFromRoute && ( + + )} {children} - + ); } type LinkedActionNotFoundGateProps = { reportActionIDFromRoute: string; - children: ReactNode; }; -function LinkedActionNotFoundGate({reportActionIDFromRoute, children}: LinkedActionNotFoundGateProps) { +function LinkedActionNotFoundGate({reportActionIDFromRoute}: LinkedActionNotFoundGateProps) { const route = useRoute(); const navigation = useNavigation(); const navigatorKey = navigation.getState()?.key; @@ -147,7 +146,7 @@ function LinkedActionNotFoundGate({reportActionIDFromRoute, children}: LinkedAct useAutoNavigateForDeletedLinkedAction(isLinkedActionUnavailable, navigateToEndOfReport); - return children; + return null; } LinkedActionNotFoundGuard.displayName = 'LinkedActionNotFoundGuard'; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index fefda07db672..ab0d49e5bf7b 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -203,11 +203,21 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // Owned here rather than by the callout, which unmounts as the layout and composer change size. useRetireMerchantRuleSuggestionOnLeave(reportID); + // Clearing a link after sending returns to the live tail of the same list. Only opening a link + // needs fresh initial positioning, including reopening the same action after its link was cleared. + const [linkedActionListState, setLinkedActionListState] = useState({reportActionID: reportActionIDFromRoute, version: 0}); + if (linkedActionListState.reportActionID !== reportActionIDFromRoute) { + setLinkedActionListState({ + reportActionID: reportActionIDFromRoute, + version: linkedActionListState.version + (reportActionIDFromRoute ? 1 : 0), + }); + } + // 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, + linkedActionListState.version, hasOnceLoadedReportActions ? 'hydrated' : 'initial', hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, ].join(':'); diff --git a/tests/navigation/LinkedActionNotFoundGuardTest.tsx b/tests/navigation/LinkedActionNotFoundGuardTest.tsx index b464add1f92b..c1cda0fd16d5 100644 --- a/tests/navigation/LinkedActionNotFoundGuardTest.tsx +++ b/tests/navigation/LinkedActionNotFoundGuardTest.tsx @@ -16,6 +16,8 @@ const NAVIGATOR_KEY = 'test-navigator-key'; const mockSetParams = jest.fn(); const mockCleanStaleBackToParam = jest.fn(); const mockIsNavigationReady = jest.fn(() => Promise.resolve()); +const mockChildrenMount = jest.fn(); +const mockChildrenUnmount = jest.fn(); jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, @@ -43,7 +45,7 @@ jest.mock('@react-navigation/native', () => { useRoute: () => ({ key: ROUTE_KEY, name: 'Report', - params: mockRouteParams, + params: {...mockRouteParams}, }), useNavigation: () => ({ getState: () => ({key: NAVIGATOR_KEY}), @@ -83,23 +85,33 @@ jest.mock('@libs/ReportUtils', () => ({ type UseOnyxReturn = [unknown, {status: string}]; let mockLinkedAction: ReportAction | null | undefined; let mockIsLoadingInitialReportActions: boolean; - -jest.mock('@hooks/useOnyx', () => ({ - __esModule: true, - default: (key: string): UseOnyxReturn => { - if (key.startsWith('reportActions_')) { - return [mockLinkedAction, {status: 'loaded'}]; - } - if (key.startsWith('reportLoadingState_')) { - return [mockIsLoadingInitialReportActions, {status: 'loaded'}]; - } - if (key.startsWith('report_')) { - return [{reportID: '12345', type: 'chat'}, {status: 'loaded'}]; - } - // DERIVED.VISIBLE_REPORT_ACTIONS - return [undefined, {status: 'loaded'}]; - }, -})); +let mockOnyxVersion = 0; +const mockOnyxListeners = new Set<() => void>(); + +jest.mock('@hooks/useOnyx', () => { + const {useSyncExternalStore} = jest.requireActual('react'); + const subscribe = (listener: () => void) => { + mockOnyxListeners.add(listener); + return () => mockOnyxListeners.delete(listener); + }; + return { + __esModule: true, + default: function useMockOnyx(key: string): UseOnyxReturn { + useSyncExternalStore(subscribe, () => mockOnyxVersion); + if (key.startsWith('reportActions_')) { + return [mockLinkedAction, {status: 'loaded'}]; + } + if (key.startsWith('reportLoadingState_')) { + return [mockIsLoadingInitialReportActions, {status: 'loaded'}]; + } + if (key.startsWith('report_')) { + return [{reportID: '12345', type: 'chat'}, {status: 'loaded'}]; + } + // DERIVED.VISIBLE_REPORT_ACTIONS + return [undefined, {status: 'loaded'}]; + }, + }; +}); function createReportAction(overrides: Partial = {}): ReportAction { return { @@ -114,6 +126,10 @@ function createReportAction(overrides: Partial = {}): ReportAction } function TestChildren() { + React.useEffect(() => { + mockChildrenMount(); + return () => mockChildrenUnmount(); + }, []); return ; } @@ -122,6 +138,8 @@ describe('LinkedActionNotFoundGuard', () => { mockSetParams.mockClear(); mockCleanStaleBackToParam.mockClear(); mockIsNavigationReady.mockClear(); + mockChildrenMount.mockClear(); + mockChildrenUnmount.mockClear(); mockRouteParams.reportID = REPORT_ID; mockRouteParams.reportActionID = REPORT_ACTION_ID; mockLinkedAction = createReportAction(); @@ -129,6 +147,29 @@ describe('LinkedActionNotFoundGuard', () => { mockIsReportActionVisible = true; }); + it('preserves the report content when a link is cleared or a different link is opened', () => { + // Given a linked report has mounted its list and composer. + const view = render( + + + , + ); + + // When sending clears the link, and the reader subsequently opens another link. + for (const reportActionID of ['', 'another-action', undefined, REPORT_ACTION_ID]) { + mockRouteParams.reportActionID = reportActionID; + view.rerender( + + + , + ); + + // Then the report keeps its mounted list, editor, and keyboard state. + expect(mockChildrenMount).toHaveBeenCalledTimes(1); + expect(mockChildrenUnmount).not.toHaveBeenCalled(); + } + }); + it('renders children when linked action exists', () => { render( @@ -165,6 +206,7 @@ describe('LinkedActionNotFoundGuard', () => { act(() => { mockIsReportActionVisible = false; + notifyMockOnyxChanges(); }); rerender( @@ -191,6 +233,7 @@ describe('LinkedActionNotFoundGuard', () => { // Now remove the action (simulating REPORT_PREVIEW being nulled when moving IOU to workspace) act(() => { mockLinkedAction = null; + notifyMockOnyxChanges(); }); rerender( @@ -229,6 +272,7 @@ describe('LinkedActionNotFoundGuard', () => { // Remove the action act(() => { mockLinkedAction = null; + notifyMockOnyxChanges(); }); rerender( @@ -266,6 +310,7 @@ describe('LinkedActionNotFoundGuard', () => { act(() => { mockLinkedAction = null; mockIsLoadingInitialReportActions = true; + notifyMockOnyxChanges(); }); rerender( @@ -278,3 +323,10 @@ describe('LinkedActionNotFoundGuard', () => { expect(mockSetParams).not.toHaveBeenCalled(); }); }); + +function notifyMockOnyxChanges() { + mockOnyxVersion += 1; + for (const listener of mockOnyxListeners) { + listener(); + } +} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index ebf8b374cbf2..d90daaae49a9 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -33,6 +33,7 @@ import type {OnViewableItemsChangedInfo} from '@legendapp/list/react-native'; import type * as ReactNavigation from '@react-navigation/native'; import type {StyleProp, ViewStyle} from 'react-native'; +import {useRoute} from '@react-navigation/native'; import React from 'react'; import {StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; @@ -78,6 +79,7 @@ jest.mock('@pages/inbox/ConciergeDraftContext', () => ({ })); const mockUseNetwork = useNetwork as jest.MockedFunction; +const mockUseRoute = useRoute as jest.MockedFunction; const mockUseIsReportLoadPending = useIsReportLoadPending as jest.MockedFunction; const mockUseOnyx = useOnyx as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; @@ -375,6 +377,7 @@ describe('ReportActionsList (body)', () => { mockShouldCallLegendListOnLoad = true; mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}); mockUseIsReportLoadPending.mockReturnValue(false); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {}}); mockUseCurrentUserPersonalDetails.mockReturnValue({ accountID: 100, @@ -718,6 +721,66 @@ describe('ReportActionsList (body)', () => { expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); }); + it('keeps the positioned list mounted when sending a message clears the linked action', async () => { + // Given a linked message has finished its initial positioning. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const view = renderReportActionsList(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + + // When the live-tail jump clears the link, a new mount would have to position again. + mockShouldCallLegendListOnLoad = false; + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: ''}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the existing list stays visible without another initial skeleton. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it.each(['2', 'another-action'])('positions a newly opened link after the previous link was cleared: %s', async (reportActionID) => { + // Given a positioned linked list is kept mounted when returning to its latest messages. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const view = renderReportActionsList(); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: ''}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + + // When the reader opens a link, even if it targets the same action again. + mockShouldCallLegendListOnLoad = false; + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then fresh positioning is covered until the new list is ready. + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + act(() => getCapturedListProps()?.onReady?.()); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + it('releases the initial viewport cover after a terminal OpenReport failure', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; From 519cb5b40353cff3c010433e4008e1c9e974d4f3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 8 Oct 2026 13:58:53 +0100 Subject: [PATCH 039/109] fix: prevent Android LegendList viewport flashes --- package-lock.json | 8 +-- package.json | 2 +- ...+001+synchronize-fabric-content-size.patch | 52 +++++++++++++++++++ patches/@legendapp/list/details.md | 10 ++++ 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch create mode 100644 patches/@legendapp/list/details.md diff --git a/package-lock.json b/package-lock.json index b1348ef8538a..8d51c8123255 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.4.0", + "@legendapp/list": "^3.6.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -12091,9 +12091,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.4.0.tgz", - "integrity": "sha512-Tlx7Uf9pRP/JbrekuWdKf1+42C9MTfl1wGkP8Lvmg2E2T9rCWQphYOs8aOkcvgbAkJ2S+WR+RTXHS2+6U+nROw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.6.0.tgz", + "integrity": "sha512-ndGQjSYdA32LWErJYbXEm4+eta+4bo0NxQO7eA3Xcvh0rXSb1mTGPbUtUkpea5w4MYciLKaE+0hoPxlMcaMtrw==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index 6b2e5987a617..9da7345e8d86 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.4.0", + "@legendapp/list": "^3.6.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch b/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch new file mode 100644 index 000000000000..c3d167d4c3a2 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch @@ -0,0 +1,52 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 0b730ec..9e14f1d 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -6097,4 +6097,12 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + // src/components/Containers.native.tsx ++// Fabric positions and the MVCP anchor update through React. Commit the content ++// size with them so an imperative Animated update cannot clamp the old viewport. ++var useContainersSize = IsNewArchitecture ? function useContainersSizeState() { ++ const [size = 0] = useArr$(["totalSize"]); ++ return size; ++} : function useContainersSizeAnimated() { ++ return useValue$("totalSize"); ++}; + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6102,7 +6110,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const animSize = useValue$("totalSize"); ++ const animSize = useContainersSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); + const style = horizontal ? { +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index a3d8d9a..1f2bcf9 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -6076,4 +6076,12 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + // src/components/Containers.native.tsx ++// Fabric positions and the MVCP anchor update through React. Commit the content ++// size with them so an imperative Animated update cannot clamp the old viewport. ++var useContainersSize = IsNewArchitecture ? function useContainersSizeState() { ++ const [size = 0] = useArr$(["totalSize"]); ++ return size; ++} : function useContainersSizeAnimated() { ++ return useValue$("totalSize"); ++}; + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6081,7 +6089,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const animSize = useValue$("totalSize"); ++ const animSize = useContainersSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); + const style = horizontal ? { diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md new file mode 100644 index 000000000000..7629edac0980 --- /dev/null +++ b/patches/@legendapp/list/details.md @@ -0,0 +1,10 @@ +# `@legendapp/list` patches + +### [@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch](@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch) + +- Reason: On Android, the entire report-action list can briefly jump to older rows while item measurements settle. LegendList updates its content size through an imperative `Animated.Value`, while Fabric row positions and the scroll-preservation anchor update through React. An early content-size update can clamp the old viewport before the matching positions mount. Read the content size through the same React store on the New Architecture so these layout changes commit together. +- Scope: Both native module formats use the synchronized size on the New Architecture. The legacy architecture retains its animated size updates, and the web entries are unchanged. Native scroll preservation and the initial loading skeleton remain enabled. +- Upstream PR/issue: Pending submission after local validation. This patch is being developed locally as part of the LegendList migration. +- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-5886543887 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: Open an expense chat containing a report preview and messages with different heights. Wait for the actual rows to replace the skeleton. The highlighted preview should stay visible while measurements settle, without a frame displaying older rows. Also scroll through older history and return to the latest messages to check scroll preservation. From 8229bcedc1fef3592f6dfbb15da2a01716aea43d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 8 Oct 2026 14:19:59 +0100 Subject: [PATCH 040/109] fix: keep linked messages visible after navigation and layout changes --- src/hooks/useLinkedReportActionScroll.ts | 120 +++++++++++++ src/hooks/useReportActionsScroll.ts | 66 ++++--- src/pages/inbox/ActionListTypes.ts | 1 + src/pages/inbox/report/ReportActionsList.tsx | 11 +- tests/ui/ReportActionsListTest.tsx | 29 +++ tests/unit/useReportActionsScrollTest.tsx | 180 ++++++++++++++++++- 6 files changed, 382 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useLinkedReportActionScroll.ts diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts new file mode 100644 index 000000000000..dca1024f4b22 --- /dev/null +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -0,0 +1,120 @@ +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; + +import CONST from '@src/CONST'; + +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; +import {Platform} from 'react-native'; + +import useReportScrollManager from './useReportScrollManager'; + +type LinkedReportActionScrollParams = { + listID: string; + linkedReportActionID: string | undefined; + targetIndex: number; + isLastAction: boolean; +}; + +/** Keeps a linked action positioned as its measured layout settles, until the reader takes over scrolling. */ +function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, isLastAction}: LinkedReportActionScrollParams) { + const isFocused = useIsFocused(); + const {getListRef} = useActionListContext(); + const reportScrollManager = useReportScrollManager(); + const [readyListID, setReadyListID] = useState(); + const [measurementVersion, setMeasurementVersion] = useState(0); + const shouldPositionRef = useRef(false); + const wasLastActionRef = useRef(false); + const cancelPositionRef = useRef<(() => void) | undefined>(undefined); + + const stopPositioning = () => { + shouldPositionRef.current = false; + cancelPositionRef.current?.(); + }; + + useEffect(() => { + shouldPositionRef.current = isFocused && !!linkedReportActionID; + wasLastActionRef.current = false; + return () => { + shouldPositionRef.current = false; + cancelPositionRef.current?.(); + }; + }, [isFocused, linkedReportActionID, listID]); + + const positionLinkedAction = useEffectEvent(() => { + if (!shouldPositionRef.current || targetIndex < 0) { + return; + } + // Once a reply follows a linked final action, normal live-tail following owns the viewport. + if (wasLastActionRef.current && !isLastAction) { + shouldPositionRef.current = false; + return; + } + wasLastActionRef.current = isLastAction; + reportScrollManager.scrollToIndex( + targetIndex, + isLastAction ? {animated: false, viewPosition: 1} : {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}, + ); + }); + + useEffect(() => { + if (!isFocused || !linkedReportActionID || readyListID !== listID || targetIndex < 0 || !shouldPositionRef.current) { + return; + } + + let frame: number | undefined; + // Row size notifications precede LegendList's position recalculation. Wait for that layout + // and the navigation transition before using the measured target position. + const transition = TransitionTracker.runAfterTransitions({ + callback: () => { + frame = requestAnimationFrame(positionLinkedAction); + }, + }); + const cancel = () => { + transition.cancel(); + if (frame !== undefined) { + cancelAnimationFrame(frame); + } + }; + cancelPositionRef.current = cancel; + return cancel; + }, [isFocused, linkedReportActionID, listID, readyListID, targetIndex, isLastAction, measurementVersion]); + + const stopPositioningFromWebInput = useEffectEvent(stopPositioning); + useEffect(() => { + if (Platform.OS !== 'web' || readyListID !== listID) { + return; + } + const scrollElement = getListRef()?.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) { + return; + } + const onScrollKey = (event: KeyboardEvent) => { + if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) { + return; + } + stopPositioningFromWebInput(); + }; + scrollElement.addEventListener('wheel', stopPositioningFromWebInput, {passive: true}); + scrollElement.addEventListener('pointerdown', stopPositioningFromWebInput, {passive: true}); + scrollElement.addEventListener('keydown', onScrollKey); + return () => { + scrollElement.removeEventListener('wheel', stopPositioningFromWebInput); + scrollElement.removeEventListener('pointerdown', stopPositioningFromWebInput); + scrollElement.removeEventListener('keydown', onScrollKey); + }; + }, [getListRef, listID, readyListID]); + + const onReady = () => setReadyListID(listID); + const onItemSizeChanged = ({index, size, previous}: {index: number; size: number; previous: number}) => { + if (!shouldPositionRef.current || readyListID !== listID || index > targetIndex || size === previous) { + return; + } + setMeasurementVersion((version) => version + 1); + }; + + return {onReady, onItemSizeChanged, stopPositioning}; +} + +export default useLinkedReportActionScroll; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 497cb7844f58..30857b271f9e 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -31,12 +31,16 @@ import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {useEffect, useEffectEvent, useState} from 'react'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLinkedReportActionScroll from './useLinkedReportActionScroll'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; type UseReportActionsScrollParams = { + /** Identifies the mounted list, including linked-message and hydration remounts. */ + listID: string; + /** The Concierge chat report */ conciergeChat: OnyxEntry; @@ -117,9 +121,14 @@ type UseReportActionsScrollResult = { /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; + + onItemSizeChanged: (info: {index: number; size: number; previous: number}) => void; + + stopLinkedMessagePositioning: () => void; }; function useReportActionsScroll({ + listID, conciergeChat, reportID, report, @@ -292,7 +301,38 @@ function useReportActionsScroll({ return () => handle?.cancel(); }, [lastAction]); + // Decide where the list should be positioned on mount. + // 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) { + initialScrollIndex = targetIndex; + // A linked final action can be taller than the viewport, so show its highlighted end. + // Unread actions still start at the New marker. Omit viewOffset for bottom alignment so + // LegendList includes the footer (for example, Concierge's thinking indicator). + initialScrollIndexParams = + initialScrollKey === linkedReportActionID && targetIndex === renderedVisibleReportActions.length - 1 + ? {viewPosition: 1} + : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + } else if (shouldFocusToTopOnMount) { + initialScrollIndex = 0; + } + + const { + onReady, + onItemSizeChanged, + stopPositioning: stopLinkedMessagePositioning, + } = useLinkedReportActionScroll({ + listID, + linkedReportActionID: initialScrollKey === linkedReportActionID ? linkedReportActionID : undefined, + targetIndex, + isLastAction: targetIndex === renderedVisibleReportActions.length - 1, + }); + const scrollToBottomAndMarkReportAsRead = () => { + stopLinkedMessagePositioning(); setIsFloatingMessageCounterVisible(false); if (unreadMarkerReportActionIndex >= 0) { @@ -327,42 +367,22 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } + stopLinkedMessagePositioning(); // `animated` is explicit because native defaults to an instant jump, which would teleport the list and lose the user's place. reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {animated: true, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; - // Data is ready when LegendList finishes its first render. const onLoad = () => { + onReady(); if (!shouldDisablePillTracking) { return; } - - // 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 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) { - initialScrollIndex = targetIndex; - // A linked final action can be taller than the viewport, so show its highlighted end. - // Unread actions still start at the New marker. Omit viewOffset for bottom alignment so - // LegendList includes the footer (for example, Concierge's thinking indicator). - initialScrollIndexParams = - initialScrollKey === linkedReportActionID && targetIndex === renderedVisibleReportActions.length - 1 - ? {viewPosition: 1} - : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; - } else if (shouldFocusToTopOnMount) { - initialScrollIndex = 0; - } - return { trackVerticalScrolling, onViewableItemsChanged, @@ -374,6 +394,8 @@ function useReportActionsScroll({ initialScrollIndex, initialScrollIndexParams, onLoad, + onItemSizeChanged, + stopLinkedMessagePositioning, }; } diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts index d429168c46cb..30be95af4a9c 100644 --- a/src/pages/inbox/ActionListTypes.ts +++ b/src/pages/inbox/ActionListTypes.ts @@ -22,6 +22,7 @@ type ActionListRef = { scrollToOffset: (params: ScrollToOffsetParams) => void; scrollToEnd: (params?: ScrollToEndParams) => void; getNativeScrollRef?: () => unknown; + getScrollableNode?: () => unknown; }; /** Ref to the underlying list instance attached via `ref={}`. */ diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 6adc81e8ad3e..442e5b995ff0 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -439,7 +439,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct initialScrollIndex, initialScrollIndexParams, onLoad, + onItemSizeChanged, + stopLinkedMessagePositioning, } = useReportActionsScroll({ + listID, reportID, conciergeChat, report, @@ -478,6 +481,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct endCorrectionFrameRef.current = undefined; }; + const handleScrollBeginDrag = () => { + stopLinkedMessagePositioning(); + stopFollowingEnd(); + }; + const scheduleEndCorrection = () => { if (readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined) { return; @@ -764,7 +772,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyboardShouldPersistTaps="handled" onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} - onScrollBeginDrag={stopFollowingEnd} + onScrollBeginDrag={handleScrollBeginDrag} onStartReached={loadOlderChatsOnStartReached} onStartReachedThreshold={PAGINATION_THRESHOLD} onViewableItemsChanged={updateVisibleItemOverflow} @@ -780,6 +788,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct maintainScrollAtEndThreshold={MAINTAIN_SCROLL_AT_END_THRESHOLD} maintainVisibleContentPosition onReady={handleListReady} + onItemSizeChanged={onItemSizeChanged} onContentSizeChange={() => { trackVerticalScrolling(undefined); scheduleEndCorrection(); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 742bd6f26c5b..9ef02417ed37 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -183,6 +183,8 @@ jest.mock('@hooks/useReportActionsScroll', () => initialScrollIndex: undefined, initialScrollIndexParams: undefined, onLoad: jest.fn(), + onItemSizeChanged: jest.fn(), + stopLinkedMessagePositioning: jest.fn(), })), ); jest.mock('@pages/inbox/report/FloatingMessageCounter', () => jest.fn(() => null)); @@ -586,6 +588,33 @@ describe('ReportActionsList (body)', () => { } }); + it('keeps linked positioning active through automatic scroll metrics until the reader drags', () => { + // Given a measured linked target can cause automatic scroll compensation before its preview settles. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as ReturnType; + const stopLinkedPositioning = scrollHookResult.stopLinkedMessagePositioning; + const listProps = getCapturedListProps(); + + // When layout compensation moves the list without a user gesture. + act(() => { + for (const offset of [200, 100]) { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 2000, width: 300}, + layoutMeasurement: {height: 100, width: 300}, + }, + }); + } + }); + + // Then later preview measurements can still correct the target, until the reader takes over. + expect(stopLinkedPositioning).not.toHaveBeenCalled(); + act(() => listProps?.onScrollBeginDrag?.()); + expect(stopLinkedPositioning).toHaveBeenCalledTimes(1); + }); + it('does not duplicate the composer spacing inside the chronological list', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 1316c153b8be..81350e93c826 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -82,7 +82,15 @@ jest.mock('@libs/Navigation/TransitionTracker', () => ({ default: { runAfterTransitions: jest.fn(({callback}: {callback: () => void}) => { mockTransitionCallbacks.push(callback); - return {cancel: jest.fn()}; + return { + cancel: jest.fn(() => { + const index = mockTransitionCallbacks.indexOf(callback); + if (index < 0) { + return; + } + mockTransitionCallbacks.splice(index, 1); + }), + }; }), }, })); @@ -115,11 +123,13 @@ jest.mock('@userActions/Report', () => ({ // --- react-navigation route --- let mockRouteParams: {reportActionID?: string; backTo?: string; shouldScrollToLatest?: string} = {}; +let mockIsFocused = true; jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, useRoute: () => ({params: mockRouteParams}), + useIsFocused: () => mockIsFocused, }; }); @@ -171,6 +181,7 @@ function makeAction(reportActionID: string, overrides: Partial = { function buildParams(overrides: Partial = {}): ScrollParams { return { + listID: REPORT_ID, reportID: REPORT_ID, conciergeChat: undefined, report: createMockReport({reportID: REPORT_ID}), @@ -209,7 +220,7 @@ async function renderScroll(overrides: Partial = {}) { function flushTransitions() { act(() => { - for (const callback of mockTransitionCallbacks) { + for (const callback of mockTransitionCallbacks.splice(0)) { callback(); } }); @@ -226,6 +237,7 @@ describe('useReportActionsScroll', () => { await waitForBatchedUpdates(); mockTransitionCallbacks.length = 0; mockRouteParams = {}; + mockIsFocused = true; mockReportRHPActiveRoute = undefined; mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; @@ -360,6 +372,170 @@ describe('useReportActionsScroll', () => { }); }); + describe('linked message positioning', () => { + it('repositions the linked parent message when returning from a thread', async () => { + // Given the linked parent report stays mounted while its thread is open. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction(LINKED_ACTION_ID), makeAction('999')]; + const params = buildParams({sortedVisibleReportActions: actions.toReversed(), renderedVisibleReportActions: actions}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + mockIsFocused = false; + rerender(params); + mockScrollToIndex.mockClear(); + + // When the parent link returns to that same report and the navigation transition finishes. + mockIsFocused = true; + rerender(params); + flushTransitions(); + + // Then the linked parent is positioned again instead of keeping the thread's stale offset. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, { + animated: false, + viewPosition: 0, + viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, + }); + }); + + it('repositions a linked PDF after its preview or preceding rows change height', async () => { + // Given the linked PDF is initially positioned using estimated row heights. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: actions})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When the preview above it finishes measuring, then the PDF itself expands. + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 1, previous: 80, size: 350})); + flushTransitions(); + + // Then the measured target is positioned again, keeping its highlight in view. + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('lets the reader scroll away before a pending preview correction runs', async () => { + // Given a preview resize has scheduled a linked-message correction. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result, rerender} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + + // When the reader takes over scrolling before the navigation/layout callback runs. + act(() => result.current.stopLinkedMessagePositioning()); + flushTransitions(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 350, size: 500})); + rerender(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]})); + flushTransitions(); + + // Then later measurements and ordinary rerenders do not reclaim the linked position. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it.each(['latest', 'badge'] as const)('stops linked corrections after explicitly jumping to the %s target', async (destination) => { + // Given a linked message still has late preview measurements to process. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')], actionBadgeTargetIndex: 1})); + act(() => result.current.onLoad()); + flushTransitions(); + + // When the reader selects another scroll destination. + act(() => { + if (destination === 'latest') { + result.current.scrollToBottomAndMarkReportAsRead(); + return; + } + result.current.scrollToActionBadgeTarget(); + }); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + + // Then a later measurement does not pull the list back to the old linked message. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('does not reposition for measurements below the linked message', async () => { + // Given the linked message is already positioned. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When only a later message changes height. + act(() => result.current.onItemSizeChanged({index: 1, previous: 80, size: 350})); + flushTransitions(); + + // Then the reader's viewport is left in place. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('leaves following a new reply to the list after positioning a linked final action', async () => { + // Given the linked final action has been positioned at the bottom. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result, rerender} = await renderScroll(buildParams({renderedVisibleReportActions: [linkedAction]})); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 1}); + mockScrollToIndex.mockClear(); + + // When a new reply arrives while normal end-following is active. + rerender(buildParams({renderedVisibleReportActions: [linkedAction, makeAction('999')]})); + flushTransitions(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + + // Then the linked action does not reclaim the viewport from the new reply. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('cancels pending positioning when the report loses focus', async () => { + // Given a linked action has a correction waiting for navigation to finish. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + + // When another report takes focus before the callback runs. + mockIsFocused = false; + rerender(params); + flushTransitions(); + + // Then the background list is not scrolled by a stale navigation callback. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('waits for the replacement list to be ready after hydration', async () => { + // Given the cached list is ready and its linked message has been positioned. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When hydration replaces that list with a newly mounted instance. + rerender({...params, listID: `${REPORT_ID}:hydrated`}); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + act(() => result.current.onLoad()); + flushTransitions(); + + // Then the correction uses the replacement list only after it is ready. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 1}); + }); + }); + describe('scrollToBottomAndMarkReportAsRead', () => { it('scrolls to bottom and marks as read when the newest action is present', async () => { // Default created === lastVisibleActionCreated → newest present. From ed4e31af1b125ba4de0d0fd29784489f378a78f4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 8 Oct 2026 16:21:30 +0100 Subject: [PATCH 041/109] fix: emit web scroll events for chronological lists Suppress hover throughout scrolling in the shared list wrappers after the chronological migration. Restore report-list render benchmarks and exercise the production start-reached pagination callback. --- src/components/FlashList/index.tsx | 2 +- src/components/FlatList/FlatList/index.tsx | 2 +- .../KeyboardDismissibleFlatList/index.tsx | 4 +- .../MoneyRequestReportActionsList.tsx | 2 +- .../MoneyRequestReportUnifiedList.tsx | 8 +- .../perf-test/ReportActionsList.perf-test.tsx | 166 ++++++++++++++++++ tests/ui/PaginationTest.tsx | 4 +- tests/ui/ReportActionsListTest.tsx | 23 +-- tests/unit/ComposerScrollEventsTest.tsx | 117 ++++++++++++ 9 files changed, 295 insertions(+), 33 deletions(-) create mode 100644 tests/perf-test/ReportActionsList.perf-test.tsx create mode 100644 tests/unit/ComposerScrollEventsTest.tsx diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index f9a7b9528735..59291cdb3fe2 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: !!inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents(); const handleScroll = (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index c50c521eee01..682d40174bd9 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -223,7 +223,7 @@ function MVCPFlatList({ }; }, []); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents(); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx index 3e8109d2d0e6..d48e9f7b96e6 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -8,10 +8,10 @@ import {useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-re import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatListContext'; -function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { +function KeyboardDismissibleFlatList({onScroll: onScrollProp, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents(); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 5344b559e761..63fb56f6ccff 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -346,7 +346,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3, shouldReserveBulkActionBarSpace && styles.bulkActionBarListSpacing]} isLoadingInitialActions={isInitialReportLoadPending} /* This list is not inverted, so the footer is the bottom of the message feed — - the same position the indicator occupies in the inverted ReportActionsList. */ + the same position the indicator occupies in ReportActionsList. */ listFooterComponent={} /> )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index e17420395d31..c4fe72bf25f2 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -48,15 +48,13 @@ function unifiedListItemType(item: UnifiedListItem) { } type MoneyRequestReportFlashListProps = FlashListProps & { - /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ + /** Ref to the underlying list, shared via the common ActionList imperative API. */ ref: ActionListRefType; }; /** - * Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based - * list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the - * ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is - * needed. The scroll manager relies on the FlashList registering into this slot. + * Forwards the shared ActionList ref through @components/FlashList to the underlying list. + * FlashList implements the common imperative API consumed by the report scroll manager. */ function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { return ( diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx new file mode 100644 index 000000000000..78ba39d7e827 --- /dev/null +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -0,0 +1,166 @@ +import {act, screen} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import navigationRef from '@libs/Navigation/navigationRef'; +import {setHasRadio} from '@libs/NetworkState'; + +import {ActionListContext} from '@pages/inbox/ActionListContext'; +import {ReactionListContext} from '@pages/inbox/ReactionListContext'; +import ReportActionsList from '@pages/inbox/report/ReportActionsList'; +import {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; + +import ComposeProviders from '@src/components/ComposeProviders'; +import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportActions} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {NavigationContainer} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; +import {measureRenders} from 'reassure'; + +import * as ReportTestUtils from '../utils/ReportTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useRoute: () => ({key: 'report', name: 'Report', params: {reportID: '1'}}), + useIsFocused: () => true, + }; +}); + +beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }), +); + +const mockOnLayout = jest.fn(); +// Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. +function buildActionListContextValue() { + return {scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, registerListRef: () => {}, getListRef: () => null}; +} +const actionListContextValue = buildActionListContextValue(); +const mockReactionListContextValue = { + showReactionList: () => {}, + hideReactionList: () => {}, + isActiveReportAction: () => false, +}; + +const TEST_USER_ACCOUNT_ID = 1; +const TEST_USER_LOGIN = 'test@test.com'; +const REPORT_ID = '1'; + +const signUpWithTestUser = () => { + TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); +}; + +function buildComment(index: number) { + const text = `Benchmark message ${index}`; + return ReportTestUtils.getFakeReportAction(index, { + actionName: index === 1 ? CONST.REPORT.ACTIONS.TYPE.CREATED : CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: TEST_USER_ACCOUNT_ID, + created: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString().replace('T', ' ').replace('Z', ''), + isAttachmentOnly: false, + message: [{type: 'COMMENT', html: text, text}], + }); +} + +const actions = Array.from({length: 500}, (_unused, index) => buildComment(index + 1)); +const reportActions: ReportActions = Object.fromEntries(actions.map((action) => [action.reportActionID, action])); +const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, lastVisibleActionCreated: actions.at(-1)?.created}); + +beforeEach(async () => { + // Initialize the network key for OfflineWithFeedback + setHasRadio(true); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + // Pre-seed the locale so LocaleContextProvider's mount effect is a no-op (setLocale early-returns), + // avoiding post-mount Onyx writes that would re-render outside act(). + await act(async () => { + signUpWithTestUser(); + await Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.DEFAULT); + + // Seed the report under test: the report, its 500 actions, and a settled loading state. + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [ + [CONST.PAGINATION_START_ID, ...actions.map((action) => action.reportActionID).reverse(), CONST.PAGINATION_END_ID], + ]); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, { + isLoadingInitialReportActions: false, + hasOnceLoadedReportActions: true, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, + }); + await waitForBatchedUpdates(); + }); +}); + +afterEach(async () => { + // Await the clear so its broadcasts settle in teardown instead of leaking into the next test. + await Onyx.clear(); + await waitForBatchedUpdates(); +}); + +// Mirror the production data flow (usePaginatedReportActions): the body reads REPORT_ACTIONS from Onyx and +// sorts inside a useOnyx selector, then runs it through the real continuous-chain pagination and the +// visibility filter. +function ReportActionsListWrapper() { + return ( + + + + + + + + + + ); +} + +// LegendList's behavior mock eagerly renders the supplied data, while the previous FlashList test used +// its virtualized implementation. Start a renderer-specific baseline instead of comparing those harnesses. +test('[ReportActionsList] should render LegendList with 500 reportActions stored', async () => { + // Given a settled report whose complete 500-action page is stored in Onyx. + const scenario = async () => { + // When the report list mounts, then its final comment must actually render. + await screen.findByTestId('report-actions-list'); + await screen.findByText('Benchmark message 500'); + }; + await waitForBatchedUpdates(); + await measureRenders(, {scenario}); +}); + +test('[ReportActionsList] should render a new comment in a report with 500 stored actions', async () => { + // Given a mounted report with 500 stored actions. + let run = 0; + const scenario = async () => { + await screen.findByTestId('report-actions-list'); + // When a new comment arrives, update the real Onyx collection and the report's latest timestamp. + // Reassure repeats this scenario, so every run must append a new action to the continuous chain. + const action = buildComment(501 + run++); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[action.reportActionID]: action}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {lastVisibleActionCreated: action.created}); + await waitForBatchedUpdates(); + }); + // Then the new comment renders before this measurement completes. + await screen.findByText(`Benchmark message ${action.reportActionID}`); + }; + await measureRenders(, {scenario}); +}); diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index e28f34f653ba..8331a9ee7247 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -344,8 +344,8 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); - // Scrolling here should trigger a new network request. - scrollToOffset(0); + // Reaching the list start must load older history without relying on the onScroll fallback. + fireEvent(within(getReportScreen()).getByLabelText(TestHelper.translateLocal('sidebarScreen.listOfChatMessages')), 'onStartReached'); await waitForBatchedUpdatesWithAct(); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 9ef02417ed37..3dfbb59608e4 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -442,15 +442,6 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); - it('delegates new-action following to LegendList without a full-viewport threshold', () => { - mockUseNetwork.mockReturnValue({isOffline: false}); - renderReportActionsList(); - - expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {dataChange: true}}); - expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(0.01); - expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); - }); - it('corrects the initial end position after the latest row has been measured', async () => { // Given a report opens at its latest action and the virtualized list finishes its initial placement. mockUseNetwork.mockReturnValue({isOffline: false}); @@ -635,16 +626,6 @@ describe('ReportActionsList (body)', () => { expect(getRenderedReportActionsListItemProps(markerAction, 1).shouldDisplayNewMarker).toBe(true); }); - 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, on: {dataChange: true}}); - }); - it('does not follow the end of a page that still has newer actions to load', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUsePaginatedReportActions.mockReturnValue({ @@ -665,6 +646,7 @@ describe('ReportActionsList (body)', () => { expect(mockLegendListMount).toHaveBeenCalledTimes(1); expect(mockLegendListUnmount).not.toHaveBeenCalled(); + mockLegendScrollToEnd.mockClear(); mockHasOnceLoadedReportActions = true; // The mocked Onyx hook does not own state, so changing its return value cannot schedule the @@ -680,8 +662,7 @@ describe('ReportActionsList (body)', () => { expect(mockLegendListMount).toHaveBeenCalledTimes(2); expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); - expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); - expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {dataChange: true}}); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); }); it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { diff --git a/tests/unit/ComposerScrollEventsTest.tsx b/tests/unit/ComposerScrollEventsTest.tsx new file mode 100644 index 000000000000..6ee1540209a4 --- /dev/null +++ b/tests/unit/ComposerScrollEventsTest.tsx @@ -0,0 +1,117 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import FlashList from '@components/FlashList'; +import MVCPFlatList from '@components/FlatList/FlatList'; +import type * as MVCPList from '@components/FlatList/FlatList'; +import ActiveHoverable from '@components/Hoverable/ActiveHoverable'; +import type * as KeyboardList from '@components/KeyboardDismissibleFlatList'; +import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList/index'; + +import type * as ComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; + +import CONST from '@src/CONST'; + +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps, ViewProps} from 'react-native'; +import type * as Reanimated from 'react-native-reanimated'; + +import React from 'react'; +import {DeviceEventEmitter, View} from 'react-native'; + +jest.mock('@hooks/useEmitComposerScrollEvents', () => jest.requireActual('@hooks/useEmitComposerScrollEvents/index.ts')); +jest.mock('@components/KeyboardDismissibleFlatList/index', () => jest.requireActual('@components/KeyboardDismissibleFlatList/index.tsx')); +jest.mock('@components/FlatList/FlatList', () => jest.requireActual('@components/FlatList/FlatList/index.tsx')); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@shopify/flash-list', () => ({ + FlashList: (props: ScrollViewProps) => { + const {ScrollView} = jest.requireActual<{ScrollView: React.ComponentType}>('react-native'); + return ; + }, +})); +jest.mock('@components/AnimatedFlatListWithCellRenderer', () => (props: ScrollViewProps) => { + const {ScrollView} = jest.requireActual<{ScrollView: React.ComponentType}>('react-native'); + return ; +}); +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useAnimatedScrollHandler: ({onScroll}: {onScroll: (event: NativeSyntheticEvent) => void}) => onScroll, + useComposedEventHandler: (handlers: Array<((event: NativeSyntheticEvent) => void) | null>) => (event: NativeSyntheticEvent) => { + for (const handler of handlers) { + handler?.(event); + } + }, +})); + +const scrollEvent = { + nativeEvent: { + contentOffset: {x: 0, y: 100}, + contentSize: {width: 300, height: 1000}, + layoutMeasurement: {width: 300, height: 500}, + }, +}; + +function HoverTarget({ref, ...props}: ViewProps & {ref?: React.Ref}) { + // Supply the DOM ref used by web hover handling while rendering through the native test renderer. + React.useImperativeHandle(ref, () => document.createElement('div'), []); + return ; +} + +describe('Composer scroll events on chronological lists', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it.each([ + ['FlashList', FlashList], + ['MVCP FlatList', MVCPFlatList], + ['keyboard dismissible FlatList', KeyboardDismissibleFlatList], + ] as const)('%s suppresses hover throughout scrolling and forwards the scroll event', (_name, List) => { + // Given a chronological list, as used by expense chats after the migration. + const emit = jest.spyOn(DeviceEventEmitter, 'emit'); + const onScroll = jest.fn(); + render( + <> + } + inverted={false} + onScroll={onScroll} + /> + + {(isHovered) => ( + + )} + + , + ); + + // When scrolling continues, hover must remain suppressed until the last event settles. + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + expect(onScroll).toHaveBeenCalledWith(scrollEvent); + expect(emit).toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, true); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('idle'); + act(() => jest.advanceTimersByTime(200)); + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('idle'); + act(() => jest.advanceTimersByTime(200)); + expect(emit).not.toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, false); + + // Then hover resumes once, after scrolling ends. + act(() => jest.advanceTimersByTime(50)); + expect(emit.mock.calls.filter(([event]) => event === CONST.EVENTS.SCROLLING)).toEqual([ + [CONST.EVENTS.SCROLLING, true], + [CONST.EVENTS.SCROLLING, false], + ]); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + }); +}); From d25f5b663d5c6be6eaf95ad9c9f922a7a20573cb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 9 Oct 2026 14:29:30 +0100 Subject: [PATCH 042/109] fix: preserve linked-message position through late layout --- patches/react-native/details.md | 2 +- src/hooks/useLinkedReportActionScroll.ts | 20 +++++- src/hooks/useReportActionsScroll.ts | 4 ++ src/pages/inbox/ActionListTypes.ts | 2 + src/pages/inbox/report/ReportActionsList.tsx | 10 ++- .../useReportActionsNewActionLiveTail.ts | 6 ++ tests/ui/ReportActionsListTest.tsx | 72 ++++++++++++++++++- .../useReportActionsNewActionLiveTailTest.ts | 15 ++++ tests/unit/useReportActionsScrollTest.tsx | 44 +++++++++++- 9 files changed, 170 insertions(+), 5 deletions(-) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 40fe8cf38219..bea8a0402cf5 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -12,7 +12,7 @@ - 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. + 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. LegendList 3.6 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. - Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. - Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 (closed without merging; the zero-sized LegendList anchor fix is not covered there) - E/App issue: 🛑 diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index dca1024f4b22..f1c89d41e997 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -4,6 +4,8 @@ import {useActionListContext} from '@pages/inbox/ActionListContext'; import CONST from '@src/CONST'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; + import {useIsFocused} from '@react-navigation/native'; import {useEffect, useEffectEvent, useRef, useState} from 'react'; import {Platform} from 'react-native'; @@ -107,6 +109,22 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, }, [getListRef, listID, readyListID]); const onReady = () => setReadyListID(listID); + const onScroll = (event: NativeSyntheticEvent) => { + if (!shouldPositionRef.current || readyListID !== listID || !linkedReportActionID || isLastAction) { + return; + } + const position = getListRef()?.current?.getState?.()?.positionByKey(linkedReportActionID); + if (position === undefined) { + return; + } + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const targetOffset = Math.max(0, Math.min(position - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, contentSize.height - layoutMeasurement.height)); + // Native anchoring can commit after the row-size correction. Check the resulting viewport + // as well, so that late adjustment cannot move a successfully positioned link out of view. + if (Math.abs(targetOffset - contentOffset.y) > 1) { + setMeasurementVersion((version) => version + 1); + } + }; const onItemSizeChanged = ({index, size, previous}: {index: number; size: number; previous: number}) => { if (!shouldPositionRef.current || readyListID !== listID || index > targetIndex || size === previous) { return; @@ -114,7 +132,7 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, setMeasurementVersion((version) => version + 1); }; - return {onReady, onItemSizeChanged, stopPositioning}; + return {onReady, onItemSizeChanged, onScroll, stopPositioning}; } export default useLinkedReportActionScroll; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 30857b271f9e..5555ee471793 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -124,6 +124,8 @@ type UseReportActionsScrollResult = { onItemSizeChanged: (info: {index: number; size: number; previous: number}) => void; + trackLinkedMessageScroll: (event: NativeSyntheticEvent) => void; + stopLinkedMessagePositioning: () => void; }; @@ -323,6 +325,7 @@ function useReportActionsScroll({ const { onReady, onItemSizeChanged, + onScroll: trackLinkedMessageScroll, stopPositioning: stopLinkedMessagePositioning, } = useLinkedReportActionScroll({ listID, @@ -395,6 +398,7 @@ function useReportActionsScroll({ initialScrollIndexParams, onLoad, onItemSizeChanged, + trackLinkedMessageScroll, stopLinkedMessagePositioning, }; } diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts index 30be95af4a9c..b366b0686c43 100644 --- a/src/pages/inbox/ActionListTypes.ts +++ b/src/pages/inbox/ActionListTypes.ts @@ -23,6 +23,8 @@ type ActionListRef = { scrollToEnd: (params?: ScrollToEndParams) => void; getNativeScrollRef?: () => unknown; getScrollableNode?: () => unknown; + /** Measured item positions supplied by LegendList. Other list implementations can omit this. */ + getState?: () => {positionByKey: (key: string) => number | undefined} | undefined; }; /** Ref to the underlying list instance attached via `ref={}`. */ diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 442e5b995ff0..4098ced5dfee 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -258,6 +258,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct listRef, (): ActionListRef => ({ getNativeScrollRef: () => legendListRef.current?.getNativeScrollRef(), + getScrollableNode: (): unknown => legendListRef.current?.getScrollableNode(), + getState: () => legendListRef.current?.getState(), scrollToEnd: (options) => { legendListRef.current?.scrollToEnd(options); }, @@ -355,7 +357,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // already-visible page mounted until hydration finishes instead of exposing intermediate estimated // layouts. The hydrated list then mounts from scratch using the full dataset. const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); - const shouldKeepInitialSnapshot = isInitialReportLoadPending && !isOffline && !showFullHistory; + const [hasReleasedInitialSnapshot, setHasReleasedInitialSnapshot] = useState(isOffline || !isInitialReportLoadPending || showFullHistory); + if (!hasReleasedInitialSnapshot && (isOffline || !isInitialReportLoadPending || showFullHistory)) { + setHasReleasedInitialSnapshot(true); + } + const shouldKeepInitialSnapshot = !hasReleasedInitialSnapshot; const reportActionsToRender = shouldKeepInitialSnapshot ? (initialReportActionsSnapshot ?? renderedVisibleReportActions) : renderedVisibleReportActions; // Report actions are stored newest-first. LegendList intentionally has no inverted mode, so @@ -440,6 +446,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct initialScrollIndexParams, onLoad, onItemSizeChanged, + trackLinkedMessageScroll, stopLinkedMessagePositioning, } = useReportActionsScroll({ listID, @@ -541,6 +548,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }; const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + trackLinkedMessageScroll(event); const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); distanceFromBottomRef.current = distanceFromBottom; diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index 5b58bb160caa..695da263499f 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -95,6 +95,12 @@ function useReportActionsNewActionLiveTail({ const liveTailJumpRef = useRef<{stage: LiveTailJumpStage}>({stage: 'idle'}); const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); const [pendingScrollActionID, setPendingScrollActionID] = useState(); + const [scrollRequestReportID, setScrollRequestReportID] = useState(reportID); + if (scrollRequestReportID !== reportID) { + setScrollRequestReportID(reportID); + setIsScrollToBottomEnabled(false); + setPendingScrollActionID(undefined); + } const setShouldScrollToBottom = useCallback((enabled: boolean) => { setIsScrollToBottomEnabled(enabled); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 3dfbb59608e4..f4b6d54b6cd0 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -20,6 +20,8 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import DateUtils from '@libs/DateUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {ActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; @@ -145,11 +147,14 @@ const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest. const mockLegendListMount = jest.fn(); const mockLegendListUnmount = jest.fn(); const mockLegendScrollToEnd = jest.fn(); +const mockLegendPositionByKey = jest.fn(() => 1500); +const mockLegendGetState = jest.fn(() => ({positionByKey: mockLegendPositionByKey})); +const mockLegendGetScrollableNode = jest.fn(() => ({scrollTop: 0})); let mockShouldCallLegendListOnLoad = true; jest.mock('@legendapp/list/react-native', () => { const reactModule = jest.requireActual('react'); const MockLegendListContent = reactModule.forwardRef<{scrollToEnd: typeof mockLegendScrollToEnd}, {onLoad?: () => void; onReady?: () => void}>(({onLoad, onReady}, ref) => { - reactModule.useImperativeHandle(ref, () => ({scrollToEnd: mockLegendScrollToEnd}), []); + reactModule.useImperativeHandle(ref, () => ({scrollToEnd: mockLegendScrollToEnd, getState: mockLegendGetState, getScrollableNode: mockLegendGetScrollableNode}), []); reactModule.useEffect(() => { mockLegendListMount(); if (mockShouldCallLegendListOnLoad) { @@ -173,6 +178,7 @@ jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.mock('@hooks/useReportActionsScroll', () => jest.fn(() => ({ listRef: {current: null}, + trackLinkedMessageScroll: jest.fn(), trackVerticalScrolling: jest.fn(), onViewableItemsChanged: jest.fn(), isFloatingMessageCounterVisible: false, @@ -442,6 +448,34 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('publishes measured positions and the scroll element through the action-list adapter', () => { + // Given a mounted LegendList with measured rows and its own scroll element. + mockUseNetwork.mockReturnValue({isOffline: false}); + const registerListRef = jest.fn(); + const contextValue = {scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, getListRef: () => null, registerListRef}; + render( + // The shared context intentionally combines stable refs with their accessors. + // eslint-disable-next-line rulesdir/context-provider-split-values + + + , + ); + + // When linked positioning reads the registered adapter rather than the raw LegendList ref. + const adapter = registerListRef.mock.calls.at(-1)?.at(0)?.current; + const position = adapter?.getState?.()?.positionByKey('linked-action'); + const scrollElement = adapter?.getScrollableNode?.(); + + // Then measurement recovery and web input cancellation can reach the real list. + expect(position).toBe(1500); + expect(mockLegendPositionByKey).toHaveBeenCalledWith('linked-action'); + expect(scrollElement).toEqual({scrollTop: 0}); + expect(mockLegendGetScrollableNode).toHaveBeenCalledTimes(1); + }); + it('corrects the initial end position after the latest row has been measured', async () => { // Given a report opens at its latest action and the virtualized list finishes its initial placement. mockUseNetwork.mockReturnValue({isOffline: false}); @@ -919,6 +953,42 @@ describe('ReportActionsList (body)', () => { expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('new-comment'); }); + it('keeps offline additions visible when reconnecting during the initial load', async () => { + // Given a cached report whose initial request is still pending. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + const view = renderReportActionsList(); + const existingAction = mockReportActions.at(1); + if (!existingAction) { + throw new Error('Expected a cached report action'); + } + const renderList = () => + view.rerender( + , + ); + + // When an action is added offline and the connection returns before hydration. + mockUseNetwork.mockReturnValue({isOffline: true}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [{...existingAction, reportActionID: 'offline-comment', created: '2023-01-03'}, ...mockReportActions], + }); + renderList(); + await waitForBatchedUpdatesWithAct(); + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('offline-comment'); + mockUseNetwork.mockReturnValue({isOffline: false}); + renderList(); + await waitForBatchedUpdatesWithAct(); + + // Then the list does not revert to the earlier cached snapshot. + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('offline-comment'); + }); + it('keeps a hydrated list mounted when its RAM-only loading entry briefly regresses', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); const view = renderReportActionsList(); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 641b4dd2caef..df4ecb40108a 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -140,6 +140,21 @@ describe('useReportActionsNewActionLiveTail', () => { expect(result.current.isScrollToBottomEnabled).toBe(true); }); + it('clears a pending sent-comment scroll when switching reports', () => { + // Given a sent action that has not entered the rendered page yet. + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const params = buildParams({hasNewerActions: false, hasNewestReportAction: true}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + act(() => newActionHandler?.(true, action, 'local')); + + // When the reader switches reports and returns before the timeout, with the row now loaded. + rerender({...params, reportID: '2'}); + rerender({...params, renderedVisibleReportActions: [action]}); + + // Then the old send request does not reclaim the viewport. + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + it('does not scroll for a payload-less realtime echo', () => { const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 81350e93c826..c87290a648d4 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -15,6 +15,7 @@ import type {ReactNode} from 'react'; import React from 'react'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import {createMockReport, getFakeReportAction} from '../utils/ReportTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -33,6 +34,7 @@ jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameR // --- useReportScrollManager --- const mockScrollToBottom = jest.fn(); const mockScrollToIndex = jest.fn(); +let mockLinkedActionPosition = 40; jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ @@ -205,7 +207,19 @@ function buildParams(overrides: Partial = {}): ScrollParams { // Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. function buildActionListContextValue() { - return {scrollOffsetRef: mockScrollOffsetRef, getScrollOffset: () => mockScrollOffsetRef.current, registerListRef: () => {}, getListRef: () => null}; + return { + scrollOffsetRef: mockScrollOffsetRef, + getScrollOffset: () => mockScrollOffsetRef.current, + registerListRef: () => {}, + getListRef: () => ({ + current: { + scrollToIndex: mockScrollToIndex, + scrollToEnd: mockScrollToBottom, + scrollToOffset: jest.fn(), + getState: () => ({positionByKey: () => mockLinkedActionPosition}), + }, + }), + }; } function wrapper({children}: {children: ReactNode}) { @@ -237,6 +251,7 @@ describe('useReportActionsScroll', () => { await waitForBatchedUpdates(); mockTransitionCallbacks.length = 0; mockRouteParams = {}; + mockLinkedActionPosition = 40; mockIsFocused = true; mockReportRHPActiveRoute = undefined; mockIsFloatingMessageCounterVisible = false; @@ -373,6 +388,33 @@ describe('useReportActionsScroll', () => { }); describe('linked message positioning', () => { + it('restores a linked message moved out of view by a late native scroll adjustment', async () => { + // Given the linked message has already landed below a measured long predecessor. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]}); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When a native layout adjustment moves its measured position beyond the viewport. + mockLinkedActionPosition = 1500; + const scrollEvent = createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 0}, contentSize: {width: 300, height: 2000}, layoutMeasurement: {width: 300, height: 600}}, + }); + act(() => result.current.trackLinkedMessageScroll(scrollEvent)); + flushTransitions(); + + // Then the linked position is restored even without another row size notification. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + mockScrollToIndex.mockClear(); + act(() => { + result.current.stopLinkedMessagePositioning(); + result.current.trackLinkedMessageScroll(scrollEvent); + }); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + it('repositions the linked parent message when returning from a thread', async () => { // Given the linked parent report stays mounted while its thread is open. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; From b6c6df2fa0d5db6a90199ac4dd18ed84102f8415 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 13 Oct 2026 18:17:37 +0100 Subject: [PATCH 043/109] fix: preserve LegendList web scroll completion --- src/pages/inbox/report/ReportActionsList.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index b14846fa30c9..8f5ffafd50a8 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -71,7 +71,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 {Platform, View} from 'react-native'; import FloatingMessageCounter from './FloatingMessageCounter'; import {ReportActionPositionContextProvider} from './ReportActionIndexContext'; @@ -792,9 +792,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isReportArchived={isReportArchived} > Date: Tue, 13 Oct 2026 18:17:57 +0100 Subject: [PATCH 044/109] fix: resolve the current composer after focus delays --- src/libs/focusComposerWithDelay/index.ts | 30 ++++++------- .../ComposerWithSuggestions.tsx | 2 +- tests/unit/focusComposerWithDelayTest.ts | 44 +++++++++++++++++++ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index bcefb4bdc200..08e9b6ca7566 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -12,16 +12,14 @@ import setTextInputSelection from './setTextInputSelection'; /** * Create a function that focuses the composer. + * A getter resolves the current input after the delay, since editing can replace it during layout. * @param [canFocusAfterDelay] Re-checked when the delay is up, so a composer that lost its focus claim meanwhile does not take focus back */ -function focusComposerWithDelay(textInput: InputType | null, delay: number = CONST.COMPOSER_FOCUS_DELAY, canFocusAfterDelay?: () => boolean): FocusComposerWithDelay { - function getIsFocused() { - if (textInput && 'isFocused' in textInput) { - return textInput.isFocused(); - } - return false; - } - +function focusComposerWithDelay( + textInput: InputType | null | (() => InputType | null), + delay: number = CONST.COMPOSER_FOCUS_DELAY, + canFocusAfterDelay?: () => boolean, +): FocusComposerWithDelay { /** * Focus the text input * @param [shouldDelay] Impose delay before focusing the text input @@ -35,8 +33,12 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON return; } - function focusAndUpdateSelection(input: InputType) { - if (getIsFocused()) { + function focusAndUpdateSelection() { + const input = typeof textInput === 'function' ? textInput() : textInput; + if (!input) { + return; + } + if ('isFocused' in input && input.isFocused()) { if (forceKeyboardIfAlreadyFocused) { requestKeyboardForFocusedComposer(input, forcedSelectionRange); } @@ -50,23 +52,19 @@ function focusComposerWithDelay(textInput: InputType | null, delay: number = CON } if (!shouldDelay) { - focusAndUpdateSelection(textInput); + focusAndUpdateSelection(); return; } await Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]); - if (!textInput) { - return; - } - // When the closing modal has a focused text input focus() needs a delay to properly work. // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. setTimeout(() => { if (canFocusAfterDelay && !canFocusAfterDelay()) { return; } - focusAndUpdateSelection(textInput); + focusAndUpdateSelection(); }, delay); }; } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 128ef3727bc5..0c815b764ee1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -315,7 +315,7 @@ function ComposerWithSuggestions({ const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY; // Another composer taking focus releases the claim, so it has to be read when the focus lands rather than now. const canFocusAfterDelay = isInSidePanelRef.current ? () => !!ReportActionComposeFocusManager.sidePanelComposerRef.current : undefined; - focusComposerWithDelay(composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); + focusComposerWithDelay(() => composerRef.current, delay, canFocusAfterDelay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); }, []); const shouldIgnoreEditSelectionResetRef = useRef(false); diff --git a/tests/unit/focusComposerWithDelayTest.ts b/tests/unit/focusComposerWithDelayTest.ts index e086cd81b168..eb65adbed324 100644 --- a/tests/unit/focusComposerWithDelayTest.ts +++ b/tests/unit/focusComposerWithDelayTest.ts @@ -41,4 +41,48 @@ describe('focusComposerWithDelay', () => { expect(focus).toHaveBeenCalled(); }); + + it('focuses an editor that mounts after the delayed focus request', async () => { + // Given an edit transition that requests focus before the input ref is attached. + const {input, focus} = createInput(); + let currentInput: HTMLTextAreaElement | null = null; + + // When the editor mounts before the focus delay ends. + await focusComposerWithDelay(() => currentInput)(true); + currentInput = input; + jest.runAllTimers(); + + // Then the mounted editor receives focus instead of losing the request. + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('does not focus an editor that unmounts before the delay ends', async () => { + // Given a focus request for an editor that is still mounted. + const {input, focus} = createInput(); + let currentInput: HTMLTextAreaElement | null = input; + await focusComposerWithDelay(() => currentInput)(true); + + // When navigation removes the editor before the delayed request runs. + currentInput = null; + jest.runAllTimers(); + + // Then the stale input does not take focus back. + expect(focus).not.toHaveBeenCalled(); + }); + + it('focuses the replacement editor instead of the old input', async () => { + // Given a pending focus request while the composer is changing its layout. + const original = createInput(); + const replacement = createInput(); + let currentInput = original.input; + await focusComposerWithDelay(() => currentInput)(true); + + // When the input ref changes before the focus delay ends. + currentInput = replacement.input; + jest.runAllTimers(); + + // Then only the current editor receives focus. + expect(original.focus).not.toHaveBeenCalled(); + expect(replacement.focus).toHaveBeenCalledTimes(1); + }); }); From 49ab56dabf4124ad34124e2c9e7e3ce8d19fd9dc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 13 Oct 2026 18:18:11 +0100 Subject: [PATCH 045/109] fix: avoid duplicate Android linked-message adjustments --- src/hooks/useLinkedReportActionScroll.ts | 12 +++++++ src/pages/inbox/ActionListTypes.ts | 2 +- tests/unit/useReportActionsScrollTest.tsx | 39 ++++++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index f1c89d41e997..f2a4fb238de4 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -54,6 +54,18 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, return; } wasLastActionRef.current = isLastAction; + if (Platform.OS === 'android' && !isLastAction && linkedReportActionID) { + const state = getListRef()?.current?.getState?.(); + const position = state?.positionByKey(linkedReportActionID); + if (position !== undefined && state?.scroll !== undefined && state.contentLength !== undefined && state.scrollLength !== undefined) { + const targetOffset = Math.max(0, Math.min(position - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, state.contentLength - state.scrollLength)); + // LegendList includes pending native anchoring in its scroll offset. An imperative + // scroll to that same offset would apply the height correction twice on Android. + if (Math.abs(targetOffset - state.scroll) <= 1) { + return; + } + } + } reportScrollManager.scrollToIndex( targetIndex, isLastAction ? {animated: false, viewPosition: 1} : {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}, diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts index b366b0686c43..a890d2947e28 100644 --- a/src/pages/inbox/ActionListTypes.ts +++ b/src/pages/inbox/ActionListTypes.ts @@ -24,7 +24,7 @@ type ActionListRef = { getNativeScrollRef?: () => unknown; getScrollableNode?: () => unknown; /** Measured item positions supplied by LegendList. Other list implementations can omit this. */ - getState?: () => {positionByKey: (key: string) => number | undefined} | undefined; + getState?: () => {positionByKey: (key: string) => number | undefined; scroll?: number; contentLength?: number; scrollLength?: number} | undefined; }; /** Ref to the underlying list instance attached via `ref={}`. */ diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index e8529f8af019..11f53c03fe4b 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -13,6 +13,7 @@ import type {ReportAction} from '@src/types/onyx'; import type {ReactNode} from 'react'; import React from 'react'; +import {Platform} from 'react-native'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; @@ -35,6 +36,7 @@ jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameR const mockScrollToBottom = jest.fn(); const mockScrollToIndex = jest.fn(); let mockLinkedActionPosition = 40; +let mockListScroll: number | undefined; jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ @@ -217,7 +219,7 @@ function buildActionListContextValue() { scrollToIndex: mockScrollToIndex, scrollToEnd: mockScrollToBottom, scrollToOffset: jest.fn(), - getState: () => ({positionByKey: () => mockLinkedActionPosition}), + getState: () => ({positionByKey: () => mockLinkedActionPosition, scroll: mockListScroll, contentLength: 5000, scrollLength: 600}), }, }), }; @@ -253,6 +255,7 @@ describe('useReportActionsScroll', () => { mockTransitionCallbacks.length = 0; mockRouteParams = {}; mockLinkedActionPosition = 40; + mockListScroll = undefined; mockIsFocused = true; mockReportRHPActiveRoute = undefined; mockIsFloatingMessageCounterVisible = false; @@ -389,6 +392,40 @@ describe('useReportActionsScroll', () => { }); describe('linked message positioning', () => { + it('lets Android preserve an already aligned linked message when a preceding row changes height', async () => { + // Given Android has already positioned the link and is preserving it during measurement. + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockLinkedActionPosition = 2690; + mockListScroll = 2650; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]}); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When LegendList updates both the measured position and its pending native scroll adjustment. + mockLinkedActionPosition = 2460; + mockListScroll = 2420; + act(() => result.current.onItemSizeChanged({index: 0, previous: 500, size: 270})); + flushTransitions(); + + // Then another imperative scroll does not apply the same correction a second time. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // A later scroll event that really moves the link still receives a correction. + mockListScroll = 2190; + act(() => + result.current.trackLinkedMessageScroll( + createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 2190}, contentSize: {width: 300, height: 5000}, layoutMeasurement: {width: 300, height: 600}}, + }), + ), + ); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + platform.restore(); + }); + it('restores a linked message moved out of view by a late native scroll adjustment', async () => { // Given the linked message has already landed below a measured long predecessor. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; From 1de8343d1e67a06887b7afca5296834d503d9feb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 19 Oct 2026 21:04:02 +0100 Subject: [PATCH 046/109] fix: keyboard focus --- src/components/Composer/implementation/index.native.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 31653968860b..7859cce9992a 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -2,7 +2,6 @@ import type {ComposerProps, ComposerRef} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; -import useBlurOnKeyboardHide from '@hooks/useBlurOnKeyboardHide'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -59,9 +58,6 @@ function Composer({ const StyleUtils = useStyleUtils(); const isInLandscapeMode = useIsInLandscapeMode(); - // Android keeps the input focused when the keyboard is dismissed with the back button/gesture. - useBlurOnKeyboardHide(textInputRef); - useEffect(() => { if (!textInputRef.current?.setSelection || !selection || isComposerFullSize) { return; From bd9ce7cbdb5d6b96a344241883a055612716b027 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 20 Oct 2026 14:48:09 +0100 Subject: [PATCH 047/109] fix: restore Android composer blur on keyboard dismissal --- src/components/Composer/implementation/index.native.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 7859cce9992a..31653968860b 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -2,6 +2,7 @@ import type {ComposerProps, ComposerRef} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; +import useBlurOnKeyboardHide from '@hooks/useBlurOnKeyboardHide'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -58,6 +59,9 @@ function Composer({ const StyleUtils = useStyleUtils(); const isInLandscapeMode = useIsInLandscapeMode(); + // Android keeps the input focused when the keyboard is dismissed with the back button/gesture. + useBlurOnKeyboardHide(textInputRef); + useEffect(() => { if (!textInputRef.current?.setSelection || !selection || isComposerFullSize) { return; From 1c04141c57af8dab0a3d1379cf76870d8b7497d4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 16:52:52 +0100 Subject: [PATCH 048/109] fix: fill the available height in report action lists --- 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 ee6d6c9b3600..79742bcc4d97 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -810,7 +810,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct data-testid={Platform.OS === 'web' ? 'report-actions-list' : undefined} testID={Platform.OS === 'web' ? undefined : 'report-actions-list'} ref={legendListRef} - style={styles.overscrollBehaviorContain} + style={[styles.flex1, styles.overscrollBehaviorContain]} data={listData} renderItem={renderItem} keyExtractor={keyExtractor} From c12c90c814174c60b903fef486749247160ab9f3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 18:47:18 +0100 Subject: [PATCH 049/109] fix: keep the reader's position when returning to a linked chat Returning from a thread or attachment re-enabled linked-message positioning, so the parent chat jumped back to the old highlighted message. Positioning now stops when the screen loses focus and only restarts for a newly opened link. Co-Authored-By: Claude Opus 5.5 --- src/hooks/useLinkedReportActionScroll.ts | 16 +++++- tests/unit/useReportActionsScrollTest.tsx | 70 +++++++++++++++++++++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index f2a4fb238de4..57295a37ea33 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -36,13 +36,25 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, }; useEffect(() => { - shouldPositionRef.current = isFocused && !!linkedReportActionID; + shouldPositionRef.current = !!linkedReportActionID; wasLastActionRef.current = false; return () => { shouldPositionRef.current = false; cancelPositionRef.current?.(); }; - }, [isFocused, linkedReportActionID, listID]); + }, [linkedReportActionID, listID]); + + useEffect(() => { + if (!isFocused) { + return; + } + + // Returning from a child screen must keep the reader's viewport, not reopen the old link. + return () => { + shouldPositionRef.current = false; + cancelPositionRef.current?.(); + }; + }, [isFocused]); const positionLinkedAction = useEffectEvent(() => { if (!shouldPositionRef.current || targetIndex < 0) { diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index c4ead60b3ac4..857f385cc371 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -459,24 +459,58 @@ describe('useReportActionsScroll', () => { expect(mockScrollToIndex).not.toHaveBeenCalled(); }); - it('repositions the linked parent message when returning from a thread', async () => { - // Given the linked parent report stays mounted while its thread is open. + it.each([false, true])('preserves the previous viewport after returning from child screens, reader scrolled away: %s', async (didScrollAway) => { + // Given a linked parent stays mounted after its initial positioning, with or without a later manual scroll. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; const actions = [makeAction(LINKED_ACTION_ID), makeAction('999')]; const params = buildParams({sortedVisibleReportActions: actions.toReversed(), renderedVisibleReportActions: actions}); const {result, rerender} = await renderScroll(params); act(() => result.current.onLoad()); flushTransitions(); - mockIsFocused = false; - rerender(params); + expect(mockScrollToIndex).toHaveBeenCalled(); + if (didScrollAway) { + act(() => result.current.stopLinkedMessagePositioning()); + } mockScrollToIndex.mockClear(); - // When the parent link returns to that same report and the navigation transition finishes. + // When the reader opens a thread, returns, then opens an attachment and returns again. + for (let visit = 0; visit < 2; visit++) { + mockIsFocused = false; + rerender(params); + mockIsFocused = true; + rerender(params); + flushTransitions(); + act(() => { + result.current.onItemSizeChanged({index: 0, previous: 80, size: 350}); + result.current.trackLinkedMessageScroll( + createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 900}, contentSize: {width: 300, height: 2000}, layoutMeasurement: {width: 300, height: 600}}, + }), + ); + }); + flushTransitions(); + + // Then neither focus nor late measurements reclaim the viewport from the reader. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + } + }); + + it('positions a new link when an initially unfocused report first becomes active', async () => { + // Given a linked report has loaded behind another screen without ever taking focus. + mockIsFocused = false; + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // When the report becomes visible for the first time. mockIsFocused = true; rerender(params); flushTransitions(); - // Then the linked parent is positioned again instead of keeping the thread's stale offset. + // Then the initial link still receives its first positioning pass. expect(mockScrollToIndex).toHaveBeenCalledWith(0, { animated: false, viewPosition: 0, @@ -484,6 +518,30 @@ describe('useReportActionsScroll', () => { }); }); + it('positions a different link opened while the report is behind a child screen', async () => { + // Given a previously positioned linked report has lost focus to a child screen. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction(UNREAD_ACTION_ID), makeAction('999')]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + mockIsFocused = false; + rerender(params); + mockScrollToIndex.mockClear(); + + // When another message link targets the same report before it regains focus. + mockRouteParams = {reportActionID: UNREAD_ACTION_ID}; + rerender(params); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + mockIsFocused = true; + rerender(params); + flushTransitions(); + + // Then the new navigation request still positions its own highlighted message. + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + it('repositions a linked PDF after its preview or preceding rows change height', async () => { // Given the linked PDF is initially positioned using estimated row heights. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; From 89ef8b220889037f2a26855bc1831145fd039f3b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 18:47:21 +0100 Subject: [PATCH 050/109] fix: keep unread chats at the newest message while older rows load Opening a chat whose unread marker is near the newest message lands at the end, but end following was only enabled for lists without an initial index. When rows above the viewport were measured after OpenReport, the list moved to older messages. Follow the end whenever the unread position lands there. Co-Authored-By: Claude Opus 5.5 --- src/pages/inbox/report/ReportActionsList.tsx | 6 +++- tests/ui/ReportActionsListTest.tsx | 38 +++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 79742bcc4d97..4704a0cb2fb5 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -566,7 +566,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct onLoad(); setLoadedInitialViewportListID(listID); readyEndListIDRef.current = listID; - shouldFollowEndOnResizeRef.current = initialScrollIndex === undefined && !hasNewerActions && userScrolledListIDRef.current !== listID; + // An unread marker near the newest action lands at the end. Rows above it are still measured after + // onReady, so keep that end in view instead of exposing older messages. + const isUnreadInitialScroll = initialScrollIndex !== undefined && !reportActionIDFromRoute && !shouldBeAlignedToTop; + const didLandAtEnd = initialScrollIndex === undefined || (isUnreadInitialScroll && !!legendListRef.current?.getState()?.isAtEnd); + shouldFollowEndOnResizeRef.current = didLandAtEnd && !hasNewerActions && userScrolledListIDRef.current !== listID; lastScrollMetricsRef.current = undefined; // The final row can grow after LegendList's initial end estimate (for example, Concierge feedback). scheduleEndCorrection(); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 7da4f50acb9c..996fb2cf16db 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -148,7 +148,7 @@ const mockLegendListMount = jest.fn(); const mockLegendListUnmount = jest.fn(); const mockLegendScrollToEnd = jest.fn(); const mockLegendPositionByKey = jest.fn(() => 1500); -const mockLegendGetState = jest.fn(() => ({positionByKey: mockLegendPositionByKey})); +const mockLegendGetState = jest.fn(() => ({positionByKey: mockLegendPositionByKey, isAtEnd: false})); const mockLegendGetScrollableNode = jest.fn(() => ({scrollTop: 0})); let mockShouldCallLegendListOnLoad = true; jest.mock('@legendapp/list/react-native', () => { @@ -584,6 +584,42 @@ describe('ReportActionsList (body)', () => { } }); + it.each([ + {isAtEnd: true, shouldFollowEnd: true}, + {isAtEnd: false, shouldFollowEnd: false}, + ])('follows late row growth after opening at an unread marker only when it lands at the end ($isAtEnd)', async ({isAtEnd, shouldFollowEnd}) => { + // Given the chat opens at an unread marker, which LegendList clamps to the end when the marker is near the newest action. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll() as ReturnType; + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 1}); + mockLegendGetState.mockReturnValue({positionByKey: mockLegendPositionByKey, isAtEnd}); + try { + renderReportActionsList(); + + // When rows above the viewport are measured after the list is ready and change the content height. + act(() => { + getCapturedListProps()?.onContentSizeChange?.(300, 1025); + }); + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + + // Then a list that landed at the end keeps the newest messages in view, and one showing older unread messages stays put. + if (shouldFollowEnd) { + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } else { + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + mockLegendGetState.mockReturnValue({positionByKey: mockLegendPositionByKey, isAtEnd: false}); + } + }); + it('cancels a queued end correction as soon as the reader starts dragging', async () => { // Given removing the latest message has queued a layout correction at the end. mockUseNetwork.mockReturnValue({isOffline: false}); From eb148d32a02d1d6c7a16acf004cbfee99f0a2d21 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 21:11:21 +0100 Subject: [PATCH 051/109] fix: stop Android chats from flashing older messages at the end When rows above the viewport get smaller, Android clamps the scroll offset to the new end, and LegendList's scroll-preservation anchor then moves the offset by the same amount again. A chat opened at its newest messages showed older messages for a few frames until the end correction ran. Patch LegendList to account for the clamp and only move the anchor by what the clamp leaves. Co-Authored-By: Claude Opus 5.5 --- ....0+002+account-for-android-end-clamp.patch | 68 +++++++++++++++++++ patches/@legendapp/list/details.md | 11 ++- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch b/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch new file mode 100644 index 000000000000..329f3102e456 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch @@ -0,0 +1,68 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 9e14f1d..0822107 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1544,6 +1544,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + } + return 0; + } ++// Android clamps the offset to the new end while mounting a content shrink, then native MVCP adds the ++// anchor delta on top. Sync JS state to that clamp and only request the rest of the movement. ++function getAndroidNativeClampedAdjust(ctx, positionDiff) { ++ const state = ctx.state; ++ const maxScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ if (state.scroll <= maxScroll + MVCP_POSITION_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.min(state.scroll + positionDiff, maxScroll); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function getProgressTowardAmount(targetDelta, nativeDelta) { + return targetDelta < 0 ? -nativeDelta : nativeDelta; + } +@@ -1770,6 +1783,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { ++ positionDiff = getAndroidNativeClampedAdjust(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 1f2bcf9..5cc805b 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1523,6 +1523,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + } + return 0; + } ++// Android clamps the offset to the new end while mounting a content shrink, then native MVCP adds the ++// anchor delta on top. Sync JS state to that clamp and only request the rest of the movement. ++function getAndroidNativeClampedAdjust(ctx, positionDiff) { ++ const state = ctx.state; ++ const maxScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ if (state.scroll <= maxScroll + MVCP_POSITION_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.min(state.scroll + positionDiff, maxScroll); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function getProgressTowardAmount(targetDelta, nativeDelta) { + return targetDelta < 0 ? -nativeDelta : nativeDelta; + } +@@ -1749,6 +1762,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { ++ positionDiff = getAndroidNativeClampedAdjust(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 7629edac0980..299029c6711f 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -4,7 +4,16 @@ - Reason: On Android, the entire report-action list can briefly jump to older rows while item measurements settle. LegendList updates its content size through an imperative `Animated.Value`, while Fabric row positions and the scroll-preservation anchor update through React. An early content-size update can clamp the old viewport before the matching positions mount. Read the content size through the same React store on the New Architecture so these layout changes commit together. - Scope: Both native module formats use the synchronized size on the New Architecture. The legacy architecture retains its animated size updates, and the web entries are unchanged. Native scroll preservation and the initial loading skeleton remain enabled. -- Upstream PR/issue: Pending submission after local validation. This patch is being developed locally as part of the LegendList migration. +- Upstream PR/issue: Fixed upstream in https://github.com/LegendApp/legend-list/commit/a2d20a9b, which isn't in a release yet. - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-5886543887 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: Open an expense chat containing a report preview and messages with different heights. Wait for the actual rows to replace the skeleton. The highlighted preview should stay visible while measurements settle, without a frame displaying older rows. Also scroll through older history and return to the latest messages to check scroll preservation. + +### [@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch](@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch) + +- Reason: On Android, a chat that opens at its newest messages can briefly show older messages before it snaps back to the end. When rows above the viewport get their real, smaller heights, the content shrinks. Android clamps the scroll offset to the new end, and LegendList's scroll-preservation anchor then moves the offset by the full shrink again. The list stays above the end by that amount until the next scroll to the end. Account for Android's clamp and only move the anchor by what the clamp doesn't cover. +- Scope: Only item-size scroll preservation on Android, and only when the content shrinks below the current offset. Data changes and active scroll targets already handle the clamp and are unchanged. iOS and web are unchanged. +- Upstream PR/issue: Not submitted yet. https://github.com/LegendApp/legend-list/pull/481 handles the same clamp for lists with a bottom inset. +- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6287555197 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: In a DM, mark the third-newest message as unread and go back to the inbox. Open the DM again. It should open at the newest messages without a frame showing older messages. Also scroll through older history and send a new message to check scroll preservation and end following. From c71756d8aeb1d0f07db6d4dcba874d622f991bd3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 21 Oct 2026 21:32:53 +0100 Subject: [PATCH 052/109] ci: retrigger unit tests Retry CI after Jest workers were killed and full-app tests timed out. Co-authored-by: Codex From 9b1bf78de3d76cf8d6805b8ff2dbb51637b8d8e7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 13:21:32 +0100 Subject: [PATCH 053/109] refactor: consolidate report scroll-to-bottom commands Co-authored-by: Codex --- .../useMoneyRequestReportScroll.ts | 4 +-- .../useReportScrollManager/index.native.ts | 25 +------------------ src/hooks/useReportScrollManager/index.ts | 4 +-- src/hooks/useReportScrollManager/types.ts | 1 - .../unit/useMoneyRequestReportScrollTest.tsx | 17 ++++++------- .../useReportActionsNewActionLiveTailTest.ts | 1 - tests/unit/useReportActionsScrollTest.tsx | 1 - tests/unit/useReportScrollManagerTest.tsx | 16 +++++++----- 8 files changed, 22 insertions(+), 47 deletions(-) diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts index a8e996a9d037..828bbbd2a695 100644 --- a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts @@ -193,7 +193,7 @@ function useMoneyRequestReportScroll({ resetKey, }); - // The indicator renders in the list footer, below the row scrollToBottom targets, so only scrollToEnd reveals it + // The indicator is below the last row, so use the manager's full-list scroll to include the footer. const {candidateAgentIDs} = useAgentZeroStatus(); const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; // Scroll once per appearance, because the label changes many times per run and re-firing would yank the viewport @@ -210,7 +210,7 @@ function useMoneyRequestReportScroll({ // Wait for the footer to lay out, otherwise there is nothing to scroll to yet const timeoutID = setTimeout(() => { - reportScrollManager.scrollToEnd(); + reportScrollManager.scrollToBottom(); }, DELAY_FOR_SCROLLING_TO_END); return () => clearTimeout(timeoutID); diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 43aa21afc658..8c9dea34397a 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,9 +1,5 @@ import {useActionListContext} from '@pages/inbox/ActionListContext'; -import type {ComponentRef} from 'react'; -// eslint-disable-next-line no-restricted-imports -import type {ScrollView} from 'react-native'; - import type {ReportScrollManagerData, ScrollToIndexOptions} from './types'; function useReportScrollManager(): ReportScrollManagerData { @@ -32,25 +28,6 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToEnd({animated: false}); }; - /** - * Scroll to the end of the FlatList. - */ - const scrollToEnd = () => { - const listRef = getListRef(); - if (!listRef?.current) { - return; - } - - const scrollViewRef = listRef.current.getNativeScrollRef?.() as ComponentRef | undefined; - // Try to scroll on underlying scrollView if available, fallback to usual listRef - if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') { - scrollViewRef.scrollToEnd({animated: false}); - return; - } - - listRef.current.scrollToEnd({animated: false}); - }; - const scrollToOffset = (offset: number) => { const listRef = getListRef(); if (!listRef?.current) { @@ -59,7 +36,7 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToOffset({offset, animated: false}); }; - return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index 9eb1ff0e1650..9ff4bafbcc4e 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -28,8 +28,6 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToEnd({animated: false}); }; - const scrollToEnd = scrollToBottom; - const scrollToOffset = (offset: number) => { const listRef = getListRef(); if (!listRef?.current) { @@ -39,7 +37,7 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToOffset({animated: true, offset}); }; - return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index c7649ecc334a..01587bf2f60e 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -18,7 +18,6 @@ type ReportScrollManagerData = { */ scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void; scrollToBottom: () => void; - scrollToEnd: () => void; scrollToOffset: (offset: number) => void; }; diff --git a/tests/unit/useMoneyRequestReportScrollTest.tsx b/tests/unit/useMoneyRequestReportScrollTest.tsx index 8c2e0270e4e3..95cad9fd3073 100644 --- a/tests/unit/useMoneyRequestReportScrollTest.tsx +++ b/tests/unit/useMoneyRequestReportScrollTest.tsx @@ -31,13 +31,12 @@ const BOTTOM_OFFSET_AT_TOP = LIST_CONTENT_HEIGHT - LIST_LAYOUT_HEIGHT; const LAST_ITEM_INDEX = 4; const mockScrollToIndex = jest.fn(); -const mockScrollToEnd = jest.fn(); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ scrollToIndex: mockScrollToIndex, - scrollToEnd: mockScrollToEnd, - scrollToBottom: jest.fn(), + scrollToBottom: mockScrollToBottom, scrollToOffset: jest.fn(), }), })); @@ -559,7 +558,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the list reveals the indicator, which lives in the footer below the row a last-item jump targets - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('should not scroll for the thinking indicator while the list is away from the bottom', async () => { @@ -576,7 +575,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the reading position is untouched - expect(mockScrollToEnd).not.toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); @@ -589,7 +588,7 @@ describe('useMoneyRequestReportScroll', () => { act(() => { jest.advanceTimersByTime(100); }); - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); // When the thinking label changes, which re-renders the list many times per run rerender(buildParams()); @@ -600,7 +599,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the viewport is not yanked down again - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('should scroll again for a new thinking-indicator run', async () => { @@ -612,7 +611,7 @@ describe('useMoneyRequestReportScroll', () => { act(() => { jest.advanceTimersByTime(100); }); - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); // When that run ends and another one starts mockCandidateAgentIDs = []; @@ -626,7 +625,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the new run's indicator is revealed too - expect(mockScrollToEnd).toHaveBeenCalledTimes(2); + expect(mockScrollToBottom).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index f41cba2ace3e..4350bbc1bad3 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -84,7 +84,6 @@ type HookParams = Parameters[0]; const reportScrollManager = { scrollToBottom: jest.fn(), scrollToIndex: jest.fn(), - scrollToEnd: jest.fn(), scrollToOffset: jest.fn(), }; diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 857f385cc371..22bfca4d91e3 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -42,7 +42,6 @@ jest.mock('@hooks/useReportScrollManager', () => ({ default: () => ({ scrollToBottom: mockScrollToBottom, scrollToIndex: mockScrollToIndex, - scrollToEnd: jest.fn(), scrollToOffset: jest.fn(), }), })); diff --git a/tests/unit/useReportScrollManagerTest.tsx b/tests/unit/useReportScrollManagerTest.tsx index 6435a8dea5e7..5d9bdf09132b 100644 --- a/tests/unit/useReportScrollManagerTest.tsx +++ b/tests/unit/useReportScrollManagerTest.tsx @@ -20,7 +20,7 @@ function buildMockListRef() { scrollToIndex: jest.fn(), scrollToOffset: jest.fn(), scrollToEnd: jest.fn(), - getNativeScrollRef: jest.fn(() => undefined), + getNativeScrollRef: jest.fn(() => ({scrollToEnd: jest.fn()})), }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const ref = {current: methods} as unknown as ActionListRefType; @@ -57,28 +57,32 @@ function renderManager() { describe('useReportScrollManager', () => { it('resolves the list ref at call time, not at init (register AFTER the manager is created)', () => { + // Given a manager created before its list ref is registered. const {result} = renderManager(); const {ref, methods} = buildMockListRef(); - // Register only after the manager hook has already run once. + // When the list registers and a scroll is requested afterward. act(() => { result.current.registerListRef(ref); }); act(() => { - result.current.manager.scrollToEnd(); + result.current.manager.scrollToBottom(); }); - // The ref was resolved lazily — the method on the later-registered ref fired. - expect(methods.scrollToEnd).toHaveBeenCalled(); + // Then the list handles the scroll so its virtualization and footer positioning stay active. + expect(methods.scrollToEnd).toHaveBeenCalledWith({animated: false}); + expect(methods.getNativeScrollRef).not.toHaveBeenCalled(); }); it('no-ops when no ref is registered', () => { + // Given a manager whose list has not mounted yet. const {result} = renderManager(); + // When any scroll command runs, then it tolerates the absent list. expect(() => { act(() => { - result.current.manager.scrollToEnd(); + result.current.manager.scrollToBottom(); result.current.manager.scrollToIndex(3); result.current.manager.scrollToOffset(100); result.current.manager.scrollToIndex(1, {animated: false}); From 311f777c097ca2a8016a4ebe1e66d4723aa2c350 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 13:37:22 +0100 Subject: [PATCH 054/109] refactor: share linked-message positioning cleanup Co-authored-by: Codex --- src/hooks/useLinkedReportActionScroll.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index 57295a37ea33..b8a77f24856d 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -34,14 +34,12 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, shouldPositionRef.current = false; cancelPositionRef.current?.(); }; + const stopPositioningFromEffect = useEffectEvent(stopPositioning); useEffect(() => { shouldPositionRef.current = !!linkedReportActionID; wasLastActionRef.current = false; - return () => { - shouldPositionRef.current = false; - cancelPositionRef.current?.(); - }; + return stopPositioningFromEffect; }, [linkedReportActionID, listID]); useEffect(() => { @@ -50,10 +48,7 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, } // Returning from a child screen must keep the reader's viewport, not reopen the old link. - return () => { - shouldPositionRef.current = false; - cancelPositionRef.current?.(); - }; + return stopPositioningFromEffect; }, [isFocused]); const positionLinkedAction = useEffectEvent(() => { @@ -107,7 +102,6 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, return cancel; }, [isFocused, linkedReportActionID, listID, readyListID, targetIndex, isLastAction, measurementVersion]); - const stopPositioningFromWebInput = useEffectEvent(stopPositioning); useEffect(() => { if (Platform.OS !== 'web' || readyListID !== listID) { return; @@ -120,14 +114,14 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) { return; } - stopPositioningFromWebInput(); + stopPositioningFromEffect(); }; - scrollElement.addEventListener('wheel', stopPositioningFromWebInput, {passive: true}); - scrollElement.addEventListener('pointerdown', stopPositioningFromWebInput, {passive: true}); + scrollElement.addEventListener('wheel', stopPositioningFromEffect, {passive: true}); + scrollElement.addEventListener('pointerdown', stopPositioningFromEffect, {passive: true}); scrollElement.addEventListener('keydown', onScrollKey); return () => { - scrollElement.removeEventListener('wheel', stopPositioningFromWebInput); - scrollElement.removeEventListener('pointerdown', stopPositioningFromWebInput); + scrollElement.removeEventListener('wheel', stopPositioningFromEffect); + scrollElement.removeEventListener('pointerdown', stopPositioningFromEffect); scrollElement.removeEventListener('keydown', onScrollKey); }; }, [getListRef, listID, readyListID]); From 5c905a665ca121165a299883b65df10a3c507dfc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 13:37:31 +0100 Subject: [PATCH 055/109] fix: preserve shared-list scroll event behavior Co-authored-by: Codex --- src/components/FlashList/index.tsx | 2 +- src/components/FlatList/FlatList/index.tsx | 2 +- .../KeyboardDismissibleFlatList/index.tsx | 2 +- tests/unit/ComposerScrollEventsTest.tsx | 45 +++++++++++++++++-- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index 59291cdb3fe2..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(); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const handleScroll = (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index 682d40174bd9..c50c521eee01 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -223,7 +223,7 @@ function MVCPFlatList({ }; }, []); - const emitComposerScrollEvents = useEmitComposerScrollEvents(); + 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 d48e9f7b96e6..effaefefceaa 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, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents(); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/tests/unit/ComposerScrollEventsTest.tsx b/tests/unit/ComposerScrollEventsTest.tsx index 6ee1540209a4..cced2c8752dc 100644 --- a/tests/unit/ComposerScrollEventsTest.tsx +++ b/tests/unit/ComposerScrollEventsTest.tsx @@ -55,7 +55,7 @@ function HoverTarget({ref, ...props}: ViewProps & {ref?: React.Ref} return ; } -describe('Composer scroll events on chronological lists', () => { +describe('Composer scroll events on shared lists', () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => { jest.clearAllTimers(); @@ -68,7 +68,7 @@ describe('Composer scroll events on chronological lists', () => { ['MVCP FlatList', MVCPFlatList], ['keyboard dismissible FlatList', KeyboardDismissibleFlatList], ] as const)('%s suppresses hover throughout scrolling and forwards the scroll event', (_name, List) => { - // Given a chronological list, as used by expense chats after the migration. + // Given an inverted list that already emits composer scroll events. const emit = jest.spyOn(DeviceEventEmitter, 'emit'); const onScroll = jest.fn(); render( @@ -77,7 +77,7 @@ describe('Composer scroll events on chronological lists', () => { testID="chronological-list" data={['message']} renderItem={() => } - inverted={false} + inverted onScroll={onScroll} /> @@ -114,4 +114,43 @@ describe('Composer scroll events on chronological lists', () => { fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); }); + + it.each([ + ['FlashList', FlashList], + ['MVCP FlatList', MVCPFlatList], + ['keyboard dismissible FlatList', KeyboardDismissibleFlatList], + ] as const)('%s forwards non-inverted scrolling without changing global hover state', (_name, List) => { + // Given a non-inverted shared list and an unrelated hovered message. + const emit = jest.spyOn(DeviceEventEmitter, 'emit'); + const onScroll = jest.fn(); + render( + <> + } + inverted={false} + onScroll={onScroll} + /> + + {(isHovered) => ( + + )} + + , + ); + fireEvent(screen.getByTestId('hoverable-message'), 'mouseEnter'); + + // When that list scrolls and its scroll-end timer would have settled. + fireEvent.scroll(screen.getByTestId('chronological-list'), scrollEvent); + act(() => jest.advanceTimersByTime(250)); + + // Then its callback still runs without broadcasting a report scroll or clearing hover. + expect(onScroll).toHaveBeenCalledWith(scrollEvent); + expect(emit).not.toHaveBeenCalledWith(CONST.EVENTS.SCROLLING, expect.anything()); + expect(screen.getByTestId('hoverable-message').props.accessibilityLabel).toBe('hovered'); + }); }); From e3b9d15b88b679716f9d114e0c31396711f06678 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 13:37:43 +0100 Subject: [PATCH 056/109] docs: clarify edit-message scroll scheduling Co-authored-by: Codex --- src/pages/inbox/report/ReportActionCompose/useEditMessage.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index f64d8b2062de..ce9e49df98fb 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -62,8 +62,9 @@ function useEditMessage({ clearAllReportActionDrafts(); - // Wait for the editor to close and the restored action row to lay out before scrolling. - // Scrolling against the editor's old height can leave the final action below the viewport. + // Give the edit-state update a frame before scrolling the newest action. + // Inline edits do not start a tracked transition, so TransitionTracker.runAfterTransitions + // cannot wait for their layout. This frame delay is not a layout-completion guarantee. if (!shouldScrollToLastMessage) { return; } From c8de6b3aa78e49ae5d4c335a83832697170593fb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 13:45:25 +0100 Subject: [PATCH 057/109] fix: preserve LegendList native scroll event cadence Co-authored-by: Codex --- src/pages/inbox/report/ReportActionsList.tsx | 7 +----- tests/ui/ReportActionsListTest.tsx | 25 +++++++++++++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 4704a0cb2fb5..9a42e1b52d14 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,5 +1,4 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import type {ActionSheetAwareScrollViewProps} from '@components/ActionSheetAwareScrollView/types'; import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; @@ -821,7 +820,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct drawDistance={REPORT_ACTIONS_DRAW_DISTANCE} recycleItems experimental_hideItemsUntilMeasured - renderScrollComponent={Platform.OS === 'web' ? undefined : renderReportActionsScrollView} + renderScrollComponent={Platform.OS === 'web' ? undefined : renderActionSheetAwareScrollView} scrollEventThrottle={16} contentContainerStyle={[styles.chatContentScrollView, styles.pb0]} onEndReached={loadNewerChatsAfterTransitions} @@ -888,7 +887,3 @@ function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsLis } export default ReportActionsList; - -function renderReportActionsScrollView(props: ActionSheetAwareScrollViewProps) { - return renderActionSheetAwareScrollView({...props, scrollEventThrottle: 16}); -} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 996fb2cf16db..bfe3ceb794cd 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,5 +1,6 @@ import {act, render, screen, waitFor} from '@testing-library/react-native'; +import type {RenderActionSheetAwareScrollViewComponent} from '@components/ActionSheetAwareScrollView/types'; import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -37,12 +38,13 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {useRoute} from '@react-navigation/native'; import React from 'react'; -import {StyleSheet} from 'react-native'; +import {Platform, StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const mockUseIsFocused = jest.fn().mockReturnValue(false); +jest.unmock('@components/ActionSheetAwareScrollView'); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -222,6 +224,8 @@ type MockLegendListProps = { onViewableItemsChanged?: (info: OnViewableItemsChangedInfo) => void; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; + renderScrollComponent?: RenderActionSheetAwareScrollViewComponent; + scrollEventThrottle?: number; onStartReached?: () => void; onScroll?: (event: { nativeEvent: { @@ -486,6 +490,25 @@ describe('ReportActionsList (body)', () => { await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); }); + it('keeps the native scroll-view throttle supplied by LegendList', () => { + // Given a report list using the action-sheet-aware native scroller. + mockUseNetwork.mockReturnValue({isOffline: false}); + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + try { + renderReportActionsList(); + const listProps = getCapturedListProps(); + + // When LegendList requests native scroll events on every frame. + const scrollView = listProps?.renderScrollComponent?.({scrollEventThrottle: 0}); + + // Then the wrapper preserves them while LegendList separately throttles the app callback. + expect(scrollView?.props.scrollEventThrottle).toBe(0); + expect(listProps?.scrollEventThrottle).toBe(16); + } finally { + platform.restore(); + } + }); + it('keeps the latest message visible when its measured height grows after opening', async () => { // Given the chat has opened at the end with a measured viewport. mockUseNetwork.mockReturnValue({isOffline: false}); From e466ab7b4c8adcfb846921e9479f3b5d19a4f2e7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 13:45:56 +0100 Subject: [PATCH 058/109] fix: retain the 250-pixel incoming-message follow range Co-authored-by: Codex --- src/hooks/useReportActionsScroll.ts | 15 ++- src/pages/inbox/report/ReportActionsList.tsx | 17 +-- tests/ui/ReportActionsListTest.tsx | 112 ++++++++++++++++++- tests/unit/useReportActionsScrollTest.tsx | 25 ++++- 4 files changed, 154 insertions(+), 15 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 7d6c7882e7ce..a254be3dc64d 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -36,6 +36,7 @@ import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; +import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; type UseReportActionsScrollParams = { /** Identifies the mounted list, including linked-message and hydration remounts. */ @@ -257,7 +258,6 @@ function useReportActionsScroll({ // 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 (!isScrollToBottomEnabled) { return; @@ -355,6 +355,19 @@ function useReportActionsScroll({ isLastAction: targetIndex === renderedVisibleReportActions.length - 1, }); + useScrollToEndOnNewMessageReceived({ + scrollOffsetRef, + lastActionID: lastAction?.reportActionID, + visibleActionsLength: sortedVisibleReportActions.length, + hasNewestReportAction, + setIsFloatingMessageCounterVisible, + scrollToEnd: () => { + stopLinkedMessagePositioning(); + reportScrollManager.scrollToBottom(); + }, + resetKey: `${reportID}:${linkedReportActionID}`, + }); + const scrollToBottomAndMarkReportAsRead = () => { stopLinkedMessagePositioning(); setIsFloatingMessageCounterVisible(false); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 9a42e1b52d14..28d9b1a34a04 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -99,7 +99,6 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; const PAGINATION_THRESHOLD = 0.75; -const MAINTAIN_SCROLL_AT_END_THRESHOLD = 0.01; const REPORT_ACTIONS_DRAW_DISTANCE = 1500; const REPORT_ACTION_COMMENT_SIZE = { @@ -257,7 +256,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const linkedReportActionID = reportActionIDFromRoute; - const {getScrollOffset} = useActionListContext(); + const {getScrollOffset, scrollOffsetRef} = useActionListContext(); const listRef = useActionListRef(); const legendListRef = useRef(null); @@ -592,7 +591,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; const previousMetrics = lastScrollMetricsRef.current; if (readyEndListIDRef.current === listID && !hasNewerActions) { - if (distanceFromBottom <= layoutMeasurement.height * MAINTAIN_SCROLL_AT_END_THRESHOLD) { + if (distanceFromBottom < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD) { shouldFollowEndOnResizeRef.current = true; } else if ( (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || @@ -767,6 +766,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct */ const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { onLayout?.(event); + const state = legendListRef.current?.getState(); + if (state && readyEndListIDRef.current === listID) { + const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); + scrollOffsetRef.current = distanceFromBottom; + distanceFromBottomRef.current = distanceFromBottom; + } scheduleEndCorrection(); if (didLayout.current) { return; @@ -841,10 +846,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} alignItemsAtEnd={!shouldBeAlignedToTop} - // Follow new actions on the latest page. Layout changes use the cancellable correction above. - maintainScrollAtEnd={!hasNewerActions && {animated: false, on: {dataChange: true}}} - // Leave the end-follow region as soon as the user starts reading older messages. - maintainScrollAtEndThreshold={MAINTAIN_SCROLL_AT_END_THRESHOLD} + // Keep the existing pixel-based new-message policy in useReportActionsScroll. + maintainScrollAtEnd={false} maintainVisibleContentPosition onReady={handleListReady} onItemSizeChanged={onItemSizeChanged} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index bfe3ceb794cd..36eaaa129cca 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -34,13 +34,14 @@ 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 type {StyleProp, ViewStyle} from 'react-native'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; import {useRoute} from '@react-navigation/native'; import React from 'react'; import {Platform, StyleSheet} from 'react-native'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const mockUseIsFocused = jest.fn().mockReturnValue(false); @@ -150,7 +151,13 @@ const mockLegendListMount = jest.fn(); const mockLegendListUnmount = jest.fn(); const mockLegendScrollToEnd = jest.fn(); const mockLegendPositionByKey = jest.fn(() => 1500); -const mockLegendGetState = jest.fn(() => ({positionByKey: mockLegendPositionByKey, isAtEnd: false})); +const mockLegendGetState = jest.fn(() => ({ + positionByKey: mockLegendPositionByKey, + isAtEnd: false, + contentLength: 1000, + scrollLength: 500, + scroll: 500, +})); const mockLegendGetScrollableNode = jest.fn(() => ({scrollTop: 0})); let mockShouldCallLegendListOnLoad = true; jest.mock('@legendapp/list/react-native', () => { @@ -216,6 +223,7 @@ type MockLegendListProps = { maintainScrollAtEnd?: {animated: boolean; on: {dataChange: boolean}} | false; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; + onLayout?: (event: LayoutChangeEvent) => void; onLoad?: () => void; onReady?: () => void; onScrollBeginDrag?: () => void; @@ -509,6 +517,75 @@ describe('ReportActionsList (body)', () => { } }); + it.each([800, 1000])('refreshes the newest-message offset without following successive viewport layouts at %i pixels', async (height) => { + // Given a reader 600 pixels from the end before the viewport grows. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalState = mockLegendGetState(); + const scrollOffsetRef = {current: 600}; + const contextValue = {scrollOffsetRef, getScrollOffset: () => scrollOffsetRef.current, getListRef: () => null, registerListRef: jest.fn()}; + render( + // The shared context intentionally combines stable refs with their accessors. + // eslint-disable-next-line rulesdir/context-provider-split-values + + + , + ); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + const frames: FrameRequestCallback[] = []; + const requestFrame = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => frames.push(callback)); + try { + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScrollBeginDrag?.(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 900}, + contentSize: {height: 2000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When successive layouts resize the viewport without new content or scroll events. + for (const viewportHeight of [height, height + 50]) { + mockLegendGetState.mockReturnValue({...originalState, contentLength: 2000, scrollLength: viewportHeight, scroll: 900}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: viewportHeight}}}))); + + // Then a future incoming message uses the current pixel offset without resizing itself starting a scroll. + expect(scrollOffsetRef.current).toBe(2000 - viewportHeight - 900); + expect(frames).toHaveLength(0); + } + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + } finally { + requestFrame.mockRestore(); + mockLegendGetState.mockReturnValue(originalState); + } + }); + + it('keeps following the newest action when the keyboard shrinks the viewport', async () => { + // Given the reader is following the newest action before opening the keyboard. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalState = mockLegendGetState(); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + try { + // When the smaller viewport temporarily leaves the newest action 300 pixels below it. + mockLegendGetState.mockReturnValue({...originalState, contentLength: 1000, scrollLength: 200, scroll: 500}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 200}}}))); + + // Then resizing keeps the existing end-following intent. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + } finally { + mockLegendGetState.mockReturnValue(originalState); + } + }); + it('keeps the latest message visible when its measured height grows after opening', async () => { // Given the chat has opened at the end with a measured viewport. mockUseNetwork.mockReturnValue({isOffline: false}); @@ -542,6 +619,31 @@ describe('ReportActionsList (body)', () => { await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); }); + it('follows content growth when the reader is 50 pixels above the newest action', async () => { + // Given a reader still within the existing 250-pixel follow distance. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScrollBeginDrag?.(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 450}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When a new message or streamed text increases the content height. + act(() => getCapturedListProps()?.onContentSizeChange?.(300, 1025)); + + // Then it follows the new content even though the reader is more than 1% from the end. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + it('does not pull the chat back down when the reader has scrolled away', async () => { // Given the chat opened at the end and the reader scrolled upward. mockUseNetwork.mockReturnValue({isOffline: false}); @@ -559,7 +661,7 @@ describe('ReportActionsList (body)', () => { }); listProps?.onScroll?.({ nativeEvent: { - contentOffset: {x: 0, y: 400}, + contentOffset: {x: 0, y: 200}, contentSize: {height: 1000, width: 300}, layoutMeasurement: {height: 500, width: 300}, }, @@ -616,7 +718,7 @@ describe('ReportActionsList (body)', () => { const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); const defaultScrollState = mockUseReportActionsScroll() as ReturnType; mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 1}); - mockLegendGetState.mockReturnValue({positionByKey: mockLegendPositionByKey, isAtEnd}); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd}); try { renderReportActionsList(); @@ -639,7 +741,7 @@ describe('ReportActionsList (body)', () => { } } finally { mockUseReportActionsScroll.mockImplementation(originalImplementation); - mockLegendGetState.mockReturnValue({positionByKey: mockLegendPositionByKey, isAtEnd: false}); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd: false}); } }); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 22bfca4d91e3..cbca996a25c5 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -679,6 +679,27 @@ describe('useReportActionsScroll', () => { }); }); + describe('new-message following', () => { + it.each([ + {distance: 50, shouldFollow: true}, + {distance: 250, shouldFollow: false}, + {distance: 300, shouldFollow: false}, + ])('preserves the pixel threshold at $distance pixels from the newest action', async ({distance, shouldFollow}) => { + // Given a loaded latest page and the reader's current bottom-relative offset. + const {rerender} = await renderScroll(); + mockScrollOffsetRef.current = distance; + mockScrollToBottom.mockClear(); + const newestAction = makeAction('2'); + + // When another user's message is appended to the chronological list. + rerender(buildParams({sortedVisibleReportActions: [newestAction, makeAction('1')], renderedVisibleReportActions: [makeAction('1'), newestAction]})); + await waitForBatchedUpdatesWithAct(); + + // Then it follows only inside the same 250-pixel range used before the migration. + expect(mockScrollToBottom).toHaveBeenCalledTimes(shouldFollow ? 1 : 0); + }); + }); + describe('scrollToBottomAndMarkReportAsRead', () => { it('scrolls to bottom and marks as read when the newest action is present', async () => { // Default created === lastVisibleActionCreated → newest present. @@ -839,7 +860,7 @@ describe('useReportActionsScroll', () => { }); describe('effects', () => { - it('leaves incoming-message following to LegendList', async () => { + it('follows incoming messages through the shared pixel-based policy', async () => { mockScrollOffsetRef.current = 0; const {rerender} = await renderScroll(); @@ -847,7 +868,7 @@ describe('useReportActionsScroll', () => { const actions = [makeAction('2'), makeAction('1')]; rerender(buildParams({sortedVisibleReportActions: actions, renderedVisibleReportActions: actions.toReversed()})); - expect(mockScrollToBottom).not.toHaveBeenCalled(); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('clears the X Replies flag once it has been applied', async () => { From 4b8a445c68c773db779a893c7e0ccf4d503035bc Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 13:46:06 +0100 Subject: [PATCH 059/109] fix: end warm report timing when covered content is visible Co-authored-by: Codex --- src/pages/inbox/report/ReportActionsList.tsx | 21 ++++--- tests/ui/ReportActionsListTest.tsx | 64 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 28d9b1a34a04..8569440e5ace 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -70,7 +70,7 @@ import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'r import type {OnyxEntry} from 'react-native-onyx'; import {LegendList} from '@legendapp/list/react-native'; -import {useRoute} from '@react-navigation/native'; +import {useIsFocused, useRoute} from '@react-navigation/native'; import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {accountIDSelector} from '@selectors/Session'; @@ -166,6 +166,7 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { * mounts it only once content is ready, so those hooks never run while a skeleton shows. */ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListContentProps) { + const isFocused = useIsFocused(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isProduction} = useEnvironment(); @@ -200,7 +201,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const reportActionIDFromRoute = route?.params?.reportActionID; const sessionStartTime = useConciergeSessionStartTime(); - const didLayout = useRef(false); const lastRequestedOldestActionIDRef = useRef(undefined); const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); @@ -504,6 +504,16 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); // Cover every initial LegendList layout, including cached chats, until the rows are positioned. const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + + useEffect(() => { + if (!isFocused || shouldShowInitialViewportSkeleton || loadedInitialViewportListID !== listID) { + return; + } + // onReady positions the list, but only this committed render has removed its cover. + // Refocus also completes new spans when the positioned list stays mounted. + markOpenReportEnd(reportID, report, {warm: true}); + }, [isFocused, listID, loadedInitialViewportListID, report, reportID, shouldShowInitialViewportSkeleton]); + const readyEndListIDRef = useRef(undefined); const lastScrollMetricsRef = useRef<{offset: number; contentHeight: number; viewportHeight: number} | undefined>(undefined); const endCorrectionFrameRef = useRef(undefined); @@ -773,13 +783,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct distanceFromBottomRef.current = distanceFromBottom; } scheduleEndCorrection(); - if (didLayout.current) { - return; - } - - didLayout.current = true; - - markOpenReportEnd(reportID, report, {warm: true}); }; // The guard only mounts this content when the report is loaded, so this is effectively unreachable. diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 36eaaa129cca..191ef48ed8bd 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -391,6 +391,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(false); mockHasOnceLoadedReportActions = true; mockIsLoadingInitialReportActions = false; mockIsLoadingOlderReportActions = false; @@ -862,6 +863,7 @@ describe('ReportActionsList (body)', () => { it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsFocused.mockReturnValue(true); mockHasOnceLoadedReportActions = false; mockIsLoadingInitialReportActions = true; mockShouldCallLegendListOnLoad = false; @@ -873,6 +875,7 @@ describe('ReportActionsList (body)', () => { getCapturedListProps()?.onReady?.(); }); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); mockHasOnceLoadedReportActions = true; view.rerender( @@ -890,6 +893,7 @@ describe('ReportActionsList (body)', () => { getCapturedListProps()?.onReady?.(); }); expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); }); it('resumes initial scroll tracking after layout and scrolling are ready', () => { @@ -1837,6 +1841,66 @@ describe('ReportActionsList (body)', () => { }); describe('Open-report telemetry', () => { + beforeEach(() => mockUseIsFocused.mockReturnValue(true)); + + it('ends the warm span when the positioned content replaces its cover', () => { + // Given cached messages whose initial list layout is still covered. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}}))); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + + // When LegendList finishes positioning and the cover is removed. + act(() => getCapturedListProps()?.onReady?.()); + + // Then telemetry records visible content once, rather than the earlier hidden layout. + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 400}}}))); + expect(mockMarkOpenReportEnd).toHaveBeenCalledTimes(1); + }); + + it('does not wait for a background OpenReport request when reopening a hydrated report', () => { + // Given a report loaded earlier in this app session with a refresh still in flight. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = true; + mockUseIsReportLoadPending.mockReturnValue(true); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + + // When the cached list finishes positioning, before that request completes. + act(() => getCapturedListProps()?.onReady?.()); + + // Then the cover clears and visible-content telemetry ends immediately. + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + + it('ends a new warm span when an already-positioned report regains focus', () => { + // Given a positioned report that remains mounted while another screen has focus. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsFocused.mockReturnValue(false); + const view = renderReportActionsList(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalled(); + + // When navigation returns without another list layout or OpenReport response. + mockUseIsFocused.mockReturnValue(true); + view.rerender( + , + ); + + // Then the visible cached content completes the new navigation span. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + it('fires markOpenReportEnd with warm:false while the initial skeleton shows', () => { mockUseNetwork.mockReturnValue({isOffline: false}); From 49eafc2b8e5af05f3628c9e13cf0e5a5a0365b53 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 14:16:30 +0100 Subject: [PATCH 060/109] fix: complete edit-message scrolling after list layout Co-authored-by: Codex --- .../MoneyRequestReportActionsList.tsx | 17 ++++++- .../MoneyRequestReportTransactionList.tsx | 5 ++ .../MoneyRequestReportUnifiedList.tsx | 5 ++ .../ReportActionCompose/useEditMessage.ts | 16 +++---- ...equestReportActionsListRejectModalTest.tsx | 46 +++++++++++++++++-- tests/unit/hooks/useEditMessage.test.ts | 32 ++++--------- 6 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 4eb733e6b67f..98946aff2f06 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -230,6 +230,20 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon completeSkippedMarkAsRead, }); + const [editScrollRequestID, setEditScrollRequestID] = useState(0); + const completedEditScrollRequestIDRef = useRef(0); + const requestScrollToNewestAfterEdit = () => { + // Force a layout commit even when closing the editor leaves the row's height unchanged. + setEditScrollRequestID((requestID) => requestID + 1); + }; + const onCommitLayoutEffect = () => { + if (editScrollRequestID <= completedEditScrollRequestIDRef.current) { + return; + } + completedEditScrollRequestIDRef.current = editScrollRequestID; + scrollToBottom(); + }; + // Wait for the actions to arrive before jumping, and clear the route param afterwards so a later remount // doesn't yank the user back down. useEffect(() => { @@ -249,7 +263,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const isNewestReportAction = indexWithinReportActions === visibleReportActions.length - 1; return ( - + void; + /** Called after FlashList finishes measuring and committing its rows. */ + onCommitLayoutEffect?: () => void; + /** FlashList onScroll callback. */ onScroll: (event: NativeSyntheticEvent) => void; @@ -189,6 +192,7 @@ function MoneyRequestReportTransactionList({ onLastItemIndexChange, accessibilityLabel, onListLayout, + onCommitLayoutEffect, onScroll, onScrollBeginDrag, onContentSizeChange, @@ -467,6 +471,7 @@ function MoneyRequestReportTransactionList({ onLastItemIndexChange={onLastItemIndexChange} accessibilityLabel={accessibilityLabel} onLayout={onListLayout} + onCommitLayoutEffect={onCommitLayoutEffect} onScroll={onScroll} onScrollBeginDrag={onScrollBeginDrag} onContentSizeChange={onContentSizeChange} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index c4fe72bf25f2..44a681fdab31 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -98,6 +98,9 @@ type MoneyRequestReportUnifiedListProps = { /** Called when the list lays out. */ onLayout: (event: LayoutChangeEvent) => void; + /** Called after FlashList finishes measuring and committing its rows. */ + onCommitLayoutEffect?: () => void; + onScroll: (event: NativeSyntheticEvent) => void; /** Called when the user starts dragging the list. */ @@ -140,6 +143,7 @@ function MoneyRequestReportUnifiedList({ listRef, accessibilityLabel, onLayout, + onCommitLayoutEffect, onScroll, onScrollBeginDrag, onContentSizeChange, @@ -357,6 +361,7 @@ function MoneyRequestReportUnifiedList({ maintainVisibleContentPosition={{autoscrollToBottomThreshold: undefined}} onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={handleLayout} + onCommitLayoutEffect={onCommitLayoutEffect} onEndReached={onEndReached} onEndReachedThreshold={0.75} onStartReached={onStartReached} diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index ce9e49df98fb..f04852e7ddde 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -62,20 +62,16 @@ function useEditMessage({ clearAllReportActionDrafts(); - // Give the edit-state update a frame before scrolling the newest action. - // Inline edits do not start a tracked transition, so TransitionTracker.runAfterTransitions - // cannot wait for their layout. This frame delay is not a layout-completion guarantee. if (!shouldScrollToLastMessage) { return; } - requestAnimationFrame(() => { - if (scrollToLastMessage) { - scrollToLastMessage(); - return; - } - reportScrollManager.scrollToBottom(); - }); + // The list owns this request after the editor unmounts and waits for its layout commit. + if (scrollToLastMessage) { + scrollToLastMessage(); + return; + } + reportScrollManager.scrollToBottom(); } /** diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index 7332aec89f59..caa688d7005e 100644 --- a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx +++ b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx @@ -39,8 +39,11 @@ const MOCK_UNIFIED_LAST_ITEM_INDEX = 37; const mockScrollToIndex = jest.fn(); const mockScrollToEnd = jest.fn(); const mockScrollToOffset = jest.fn(); +const mockNewestActionUnmount = jest.fn(); let mockIsNewestReportAction: boolean | undefined; let mockScrollToNewestAction: (() => void) | undefined; +let mockOnCommitLayoutEffect: (() => void) | undefined; +let mockShouldRenderNewestAction = true; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -97,6 +100,7 @@ jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionList' isLoadingInitialActions, listRef, onLastItemIndexChange, + onCommitLayoutEffect, visibleReportActions, renderReportAction, }: { @@ -108,17 +112,19 @@ jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionList' scrollToOffset: typeof mockScrollToOffset; }>; onLastItemIndexChange?: (index: number) => void; + onCommitLayoutEffect?: () => void; visibleReportActions: ReportAction[]; renderReportAction: (reportAction: ReportAction, index: number) => React.ReactElement; }) => { ReactActual.useImperativeHandle(listRef, () => ({scrollToIndex: mockScrollToIndex, scrollToEnd: mockScrollToEnd, scrollToOffset: mockScrollToOffset})); ReactActual.useLayoutEffect(() => onLastItemIndexChange?.(MOCK_UNIFIED_LAST_ITEM_INDEX), [onLastItemIndexChange]); + mockOnCommitLayoutEffect = onCommitLayoutEffect; const newestAction = visibleReportActions.at(-1); return ( {isLoadingInitialActions ? : null} - {newestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null} + {newestAction && mockShouldRenderNewestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null} {listFooterComponent} ); @@ -202,6 +208,12 @@ jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => { jest.requireActual('@pages/inbox/report/ReportActionIndexContext'); return jest.fn(() => { + ReactActual.useEffect( + () => () => { + mockNewestActionUnmount(); + }, + [], + ); mockIsNewestReportAction = ReactActual.useContext(ReportActionIndexContextActual).isNewest; mockScrollToNewestAction = ReactActual.useContext(ReportActionScrollToNewestContextActual); return null; @@ -313,6 +325,8 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { jest.clearAllMocks(); mockIsNewestReportAction = undefined; mockScrollToNewestAction = undefined; + mockOnCommitLayoutEffect = undefined; + mockShouldRenderNewestAction = true; jest.spyOn(NativeNavigation, 'useIsFocused').mockReturnValue(true); mockUseIsReportLoadPending.mockReturnValue(false); mockUseNetwork.mockReturnValue({isOffline: false}); @@ -322,7 +336,8 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { }); }); - it('should use the unified list index when the newest action requests a bottom scroll', async () => { + it('keeps newest-action scroll requests until FlashList commits after the editor unmounts', async () => { + // Given a loaded money report with an editable newest action and an unchanged row height. await act(async () => { await Onyx.multiSet({ [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_REPORT_ID}` as const]: mockReport, @@ -341,9 +356,34 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { await waitForBatchedUpdatesWithAct(); expect(mockIsNewestReportAction).toBe(true); - act(() => mockScrollToNewestAction?.()); + mockScrollToIndex.mockClear(); + act(() => mockOnCommitLayoutEffect?.()); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // When closing the editor queues a request and unmounts its consumer, without a size-change event. + const requestScroll = mockScrollToNewestAction; + mockShouldRenderNewestAction = false; + act(() => requestScroll?.()); + expect(mockNewestActionUnmount).toHaveBeenCalledTimes(1); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // Then the list's layout commit consumes the request once and preserves its indexed landing. + const firstCommit = mockOnCommitLayoutEffect; + act(() => { + firstCommit?.(); + firstCommit?.(); + }); expect(mockScrollToIndex).toHaveBeenCalledWith({index: MOCK_UNIFIED_LAST_ITEM_INDEX, animated: false, viewPosition: 1}); + expect(mockScrollToIndex).toHaveBeenCalledTimes(1); expect(mockScrollToEnd).not.toHaveBeenCalled(); + + // A later edit needs its own layout commit; a repeated older callback cannot consume it. + mockShouldRenderNewestAction = true; + act(() => requestScroll?.()); + act(() => firstCommit?.()); + expect(mockScrollToIndex).toHaveBeenCalledTimes(1); + act(() => mockOnCommitLayoutEffect?.()); + expect(mockScrollToIndex).toHaveBeenCalledTimes(2); }); it('should show reject educational modal when reject option is selected and explanation has NOT been dismissed', async () => { diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index 01e2081db571..786323f255c7 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -90,20 +90,10 @@ function makeDebouncedValidator({flushResult}: {flushResult: boolean}): Debounce } describe('useEditMessage', () => { - let pendingAnimationFrame: FrameRequestCallback | undefined; - beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); - beforeEach(() => { - pendingAnimationFrame = undefined; - jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { - pendingAnimationFrame = callback; - return 1; - }); - }); - afterEach(async () => { await act(async () => { await Onyx.clear(); @@ -112,10 +102,6 @@ describe('useEditMessage', () => { jest.restoreAllMocks(); }); - function finishActionLayout() { - act(() => pendingAnimationFrame?.(0)); - } - function renderUseEditMessage(overrides?: Partial) { const report = LHNTestUtils.getFakeReport(); const reportAction = LHNTestUtils.getFakeReportAction(); @@ -160,8 +146,10 @@ describe('useEditMessage', () => { }); it('scrolls to the bottom after deleting the newest message draft without a list-specific callback', () => { + // Given the newest message uses the list registered with the scroll manager. const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + // When deleting the draft closes its editor. act(() => { hook.result.current.publishDraft(' '); }); @@ -169,15 +157,16 @@ describe('useEditMessage', () => { mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); }); - expect(mockScrollToBottom).not.toHaveBeenCalled(); - finishActionLayout(); + // Then the list receives the request immediately and owns its layout scheduling. expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('uses the list-specific scroll after deleting the newest message draft', () => { + // Given the list supplies its own edit-scroll request handler. const scrollToLastMessage = jest.fn(); const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true, scrollToLastMessage}); + // When deleting the draft closes its editor. act(() => { hook.result.current.publishDraft(' '); }); @@ -185,23 +174,20 @@ describe('useEditMessage', () => { mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); }); - expect(scrollToLastMessage).not.toHaveBeenCalled(); - finishActionLayout(); + // Then scheduling belongs to that list instead of a callback on the unmounting editor. expect(scrollToLastMessage).toHaveBeenCalledTimes(1); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); - it('scrolls to the final saved action after the editor layout has been replaced', () => { + it('requests the newest action after saving without scheduling an editor frame', () => { // Given a draft on the newest action. const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); - // When the edit is submitted, its optimistic update is queued before layout settles. + // When the edit is submitted and the editor closes. act(() => hook.result.current.publishDraft('Updated message')); expect(mockEditReportComment).toHaveBeenCalledTimes(1); - expect(mockScrollToBottom).not.toHaveBeenCalled(); - // Then scrolling runs after the restored action row gets a layout frame. - finishActionLayout(); + // Then the list already owns the scroll request before the editor can unmount. expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); From 8dc16d8205dfe912124c8e3f5d552b1ba2afc255 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 22 Oct 2026 14:22:09 +0100 Subject: [PATCH 061/109] docs: link upstream LegendList and Android anchoring fixes Co-authored-by: Codex --- patches/@legendapp/list/details.md | 6 +++++- patches/react-native/details.md | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 299029c6711f..262148fc96ce 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -13,7 +13,11 @@ - Reason: On Android, a chat that opens at its newest messages can briefly show older messages before it snaps back to the end. When rows above the viewport get their real, smaller heights, the content shrinks. Android clamps the scroll offset to the new end, and LegendList's scroll-preservation anchor then moves the offset by the full shrink again. The list stays above the end by that amount until the next scroll to the end. Account for Android's clamp and only move the anchor by what the clamp doesn't cover. - Scope: Only item-size scroll preservation on Android, and only when the content shrinks below the current offset. Data changes and active scroll targets already handle the clamp and are unchanged. iOS and web are unchanged. -- Upstream PR/issue: Not submitted yet. https://github.com/LegendApp/legend-list/pull/481 handles the same clamp for lists with a bottom inset. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/582 upstreams this patch. https://github.com/LegendApp/legend-list/pull/481 covers the related bottom-inset case. Keep this patch until the installed LegendList release includes this fix. - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6287555197 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: In a DM, mark the third-newest message as unread and go back to the inbox. Open the DM again. It should open at the newest messages without a frame showing older messages. Also scroll through older history and send a new message to check scroll preservation and end following. + +### Related upstream work + +- https://github.com/LegendApp/legend-list/pull/583 refreshes the cached end-follow state when `maintainScrollAtEndThreshold` changes. This supports a fixed pixel follow distance across viewport changes when using LegendList's built-in end following. diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 57efdef9932e..16582fef9e62 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -14,7 +14,10 @@ 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. LegendList 3.6 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. - Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. -- Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 (closed without merging; the zero-sized LegendList anchor fix is not covered there) +- Upstream PR/issue: https://github.com/react/react-native/pull/57959 covers z-index anchor selection. The follow-up guard preserving LegendList's positioned, zero-sized anchor is proposed in https://github.com/react/react-native/pull/57959#issuecomment-6341492122. +- Reproducer: https://github.com/react/react-native/pull/59360 adds the RNTester reproduction, without a runtime fix. The standalone reproduction and proposed anchor patch are in https://github.com/margelo/react-native-mvcp-reproducer. +- Earlier proposal: https://github.com/react/react-native/pull/46247 closed without merging. +- Removal: Drop this patch only when the adopted React Native release preserves all three anchoring behaviors described above. - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) From e0b5420b76f4b9a53dceed3dd54a25020c0cdc9a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 24 Oct 2026 20:21:47 +0100 Subject: [PATCH 062/109] ci: retrigger Reassure performance tests Co-authored-by: Codex From df38ea584111c7e156dfcb76cd9a504c88d3be4f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sun, 25 Oct 2026 01:04:21 +0000 Subject: [PATCH 063/109] test: bound the LegendList row-write benchmark page Keep 500 cached actions while measuring a 50-action displayed page. The LegendList Jest mock eagerly renders every supplied row, which exhausted the performance runner heap with the unbounded fixture. Verify that edits update a displayed row and start renderer-specific baselines for the new fixture. Co-authored-by: Codex --- .../ReportActionsListRowWrites.perf-test.tsx | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx b/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx index 99c7cac7e7c0..0f14f680e315 100644 --- a/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx +++ b/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx @@ -5,6 +5,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {setHasRadio} from '@libs/NetworkState'; +import {getSortedReportActionsForDisplay} from '@libs/ReportActionsUtils'; import {ActionListContext} from '@pages/inbox/ActionListContext'; import {ReactionListContext} from '@pages/inbox/ReactionListContext'; @@ -69,12 +70,15 @@ const signUpWithTestUser = () => { const sortedReportActions = ReportTestUtils.getMockedSortedReportActions(500); const reportActions: ReportActions = Object.fromEntries(sortedReportActions.map((action: ReportAction) => [action.reportActionID, action])); +const newestPageActionIDs = getSortedReportActionsForDisplay(reportActions, true, true) + .slice(0, 50) + .map((action) => action.reportActionID); const POLICY_ID = 'P1'; const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, policyID: POLICY_ID, lastVisibleActionCreated: sortedReportActions.at(0)?.created}); const WRITES_PER_SCENARIO = 10; -// All mock actions share one timestamp, so the initial window is ordered by reportActionID as a string and starts at action 1, then 10.. -const VISIBLE_ACTION_ID = '10'; +// All mock actions share one timestamp, so the newest page starts with the lexicographically highest action ID. +const VISIBLE_ACTION_ID = '99'; beforeEach(async () => { // Initialize the network key for OfflineWithFeedback @@ -97,6 +101,8 @@ beforeEach(async () => { outputCurrency: CONST.CURRENCY.USD, }); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); + // Keep all 500 cached actions, but bound the rendered page because LegendList's Jest mock renders every supplied row. + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [[CONST.PAGINATION_START_ID, ...newestPageActionIDs]]); await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, { isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true, @@ -136,7 +142,7 @@ function ReportActionsListWrapper() { ); } -async function measureWrites(write: (run: number, index: number) => Promise) { +async function measureWrites(write: (run: number, index: number) => Promise, verifyWrite?: (run: number, index: number) => Promise) { // Reassure runs the scenario several times against one seeded Onyx, so every run writes a value that doesn't exist yet let run = 0; const scenario = async () => { @@ -147,6 +153,7 @@ async function measureWrites(write: (run: number, index: number) => Promise Promise `2030-01-01 00:${String(run % 60).padStart(2, '0')}:${String(index).padStart(2, '0')}.000`; -test('[ReportActionsList] should re-render when one visible action is edited', async () => { +// The paginated LegendList fixture needs its own baseline instead of comparing against FlashList's virtualized fixture. +test('[ReportActionsList LegendList] should re-render when one visible action is edited', async () => { // Given 500 actions in the report, when the message of one visible action is edited repeatedly // Then reassure measures what an edit of one action costs the list - await measureWrites((run, index) => - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[VISIBLE_ACTION_ID]: {message: [{type: 'TEXT', html: `edited ${run}-${index}`, text: `edited ${run}-${index}`}]}}), + await measureWrites( + (run, index) => + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [VISIBLE_ACTION_ID]: {message: [{type: 'TEXT', html: `edited ${run}-${index}`, text: `edited ${run}-${index}`}]}, + }), + (run, index) => screen.findByText(`edited ${run}-${index}`), ); }); -test('[ReportActionsList] should re-render when only a heartbeat field of the report changes', async () => { +test('[ReportActionsList LegendList] should re-render when only a heartbeat field of the report changes', async () => { // Given 500 actions in the report, when only lastReadTime of the report changes // Then reassure measures what a field the rows don't display costs the list await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {lastReadTime: getTime(run, index)})); }); -test('[ReportActionsList] should re-render when an unrelated report is written', async () => { +test('[ReportActionsList LegendList] should re-render when an unrelated report is written', async () => { // Given 500 actions in the report, when another report in the account is created or updated // Then reassure measures what a write outside the open report costs the list await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}unrelated${run}_${index}`, {reportID: `unrelated${run}_${index}`, reportName: 'Unrelated'})); }); -test('[ReportActionsList] should re-render when an unrelated transaction is written', async () => { +test('[ReportActionsList LegendList] should re-render when an unrelated transaction is written', async () => { // Given 500 actions in the report, when a transaction of another report changes // Then reassure measures what a write outside the open report costs the list await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}T1`, {transactionID: 'T1', reportID: 'unrelated', amount: run * 100 + index})); }); -test('[ReportActionsList] should re-render when the report name changes', async () => { +test('[ReportActionsList LegendList] should re-render when the report name changes', async () => { // Given 500 actions in the report, when a field every row reads through the stable report changes // Then reassure measures the cost of a write that legitimately re-renders all rows await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportName: `Renamed ${run}-${index}`})); From 983add06fd44e33bc969e35bc0694368b4fcff42 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 26 Oct 2026 12:15:59 +0000 Subject: [PATCH 064/109] chore(deps): update LegendList to 3.7.0 Remove the upstreamed Fabric content-size patch and port the Android end-clamp patch to the new release. Co-authored-by: Codex --- package-lock.json | 8 +-- package.json | 2 +- ...+001+synchronize-fabric-content-size.patch | 52 ------------------- ...0+001+account-for-android-end-clamp.patch} | 12 ++--- patches/@legendapp/list/details.md | 11 +--- 5 files changed, 13 insertions(+), 72 deletions(-) delete mode 100644 patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch rename patches/@legendapp/list/{@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch => @legendapp+list+3.7.0+001+account-for-android-end-clamp.patch} (90%) diff --git a/package-lock.json b/package-lock.json index 20ae8830c43d..7d637852c427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.6.0", + "@legendapp/list": "^3.7.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11690,9 +11690,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.6.0.tgz", - "integrity": "sha512-ndGQjSYdA32LWErJYbXEm4+eta+4bo0NxQO7eA3Xcvh0rXSb1mTGPbUtUkpea5w4MYciLKaE+0hoPxlMcaMtrw==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.0.tgz", + "integrity": "sha512-Er2D5eKIv+rfbakJ3ra4pD7NCNtOj2m1nrU6s6Ea3dO+x3vgt0h9NE0dn5WHOLIRkPEWHiwbslgl95QxZiEkjg==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index 4d4b2fea6a2a..069afe19d45d 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.6.0", + "@legendapp/list": "^3.7.0", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch b/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch deleted file mode 100644 index c3d167d4c3a2..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch +++ /dev/null @@ -1,52 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index 0b730ec..9e14f1d 100644 ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -6097,4 +6097,12 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { - // src/components/Containers.native.tsx -+// Fabric positions and the MVCP anchor update through React. Commit the content -+// size with them so an imperative Animated update cannot clamp the old viewport. -+var useContainersSize = IsNewArchitecture ? function useContainersSizeState() { -+ const [size = 0] = useArr$(["totalSize"]); -+ return size; -+} : function useContainersSizeAnimated() { -+ return useValue$("totalSize"); -+}; - var ContainersLayer = typedMemo(function ContainersLayer2({ - children, - freshDataTransitionEpoch, -@@ -6102,7 +6110,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ - }) { - const ctx = useStateContext(); - const columnWrapperStyle = ctx.columnWrapperStyle; -- const animSize = useValue$("totalSize"); -+ const animSize = useContainersSize(); - const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); - const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); - const style = horizontal ? { -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index a3d8d9a..1f2bcf9 100644 ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -6076,4 +6076,12 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { - // src/components/Containers.native.tsx -+// Fabric positions and the MVCP anchor update through React. Commit the content -+// size with them so an imperative Animated update cannot clamp the old viewport. -+var useContainersSize = IsNewArchitecture ? function useContainersSizeState() { -+ const [size = 0] = useArr$(["totalSize"]); -+ return size; -+} : function useContainersSizeAnimated() { -+ return useValue$("totalSize"); -+}; - var ContainersLayer = typedMemo(function ContainersLayer2({ - children, - freshDataTransitionEpoch, -@@ -6081,7 +6089,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ - }) { - const ctx = useStateContext(); - const columnWrapperStyle = ctx.columnWrapperStyle; -- const animSize = useValue$("totalSize"); -+ const animSize = useContainersSize(); - const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); - const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); - const style = horizontal ? { diff --git a/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch b/patches/@legendapp/list/@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch similarity index 90% rename from patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch rename to patches/@legendapp/list/@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch index 329f3102e456..d484e54731e0 100644 --- a/patches/@legendapp/list/@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index 9e14f1d..0822107 100644 +index 59ecd66..432f476 100644 --- a/node_modules/@legendapp/list/react-native.js +++ b/node_modules/@legendapp/list/react-native.js -@@ -1544,6 +1544,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1642,6 +1642,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { } return 0; } @@ -22,7 +22,7 @@ index 9e14f1d..0822107 100644 function getProgressTowardAmount(targetDelta, nativeDelta) { return targetDelta < 0 ? -nativeDelta : nativeDelta; } -@@ -1770,6 +1783,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1884,6 +1897,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { now, positionDiff }); @@ -33,10 +33,10 @@ index 9e14f1d..0822107 100644 state.pendingNativeMVCPAdjust = { amount: positionDiff, diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index 1f2bcf9..5cc805b 100644 +index 5bf1496..8262cbd 100644 --- a/node_modules/@legendapp/list/react-native.mjs +++ b/node_modules/@legendapp/list/react-native.mjs -@@ -1523,6 +1523,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1621,6 +1621,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { } return 0; } @@ -56,7 +56,7 @@ index 1f2bcf9..5cc805b 100644 function getProgressTowardAmount(targetDelta, nativeDelta) { return targetDelta < 0 ? -nativeDelta : nativeDelta; } -@@ -1749,6 +1762,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1863,6 +1876,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { now, positionDiff }); diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 262148fc96ce..9f48869a73df 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -1,15 +1,8 @@ # `@legendapp/list` patches -### [@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch](@legendapp+list+3.6.0+001+synchronize-fabric-content-size.patch) +The Fabric content-size fix from https://github.com/LegendApp/legend-list/commit/a2d20a9b is included in [LegendList 3.7.0](https://github.com/LegendApp/legend-list/blob/v3.7.0/CHANGELOG.md#370), so its local patch has been removed. -- Reason: On Android, the entire report-action list can briefly jump to older rows while item measurements settle. LegendList updates its content size through an imperative `Animated.Value`, while Fabric row positions and the scroll-preservation anchor update through React. An early content-size update can clamp the old viewport before the matching positions mount. Read the content size through the same React store on the New Architecture so these layout changes commit together. -- Scope: Both native module formats use the synchronized size on the New Architecture. The legacy architecture retains its animated size updates, and the web entries are unchanged. Native scroll preservation and the initial loading skeleton remain enabled. -- Upstream PR/issue: Fixed upstream in https://github.com/LegendApp/legend-list/commit/a2d20a9b, which isn't in a release yet. -- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-5886543887 -- PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: Open an expense chat containing a report preview and messages with different heights. Wait for the actual rows to replace the skeleton. The highlighted preview should stay visible while measurements settle, without a frame displaying older rows. Also scroll through older history and return to the latest messages to check scroll preservation. - -### [@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch](@legendapp+list+3.6.0+002+account-for-android-end-clamp.patch) +### [@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch](@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch) - Reason: On Android, a chat that opens at its newest messages can briefly show older messages before it snaps back to the end. When rows above the viewport get their real, smaller heights, the content shrinks. Android clamps the scroll offset to the new end, and LegendList's scroll-preservation anchor then moves the offset by the full shrink again. The list stays above the end by that amount until the next scroll to the end. Account for Android's clamp and only move the anchor by what the clamp doesn't cover. - Scope: Only item-size scroll preservation on Android, and only when the content shrinks below the current offset. Data changes and active scroll targets already handle the clamp and are unchanged. iOS and web are unchanged. From 918a45f13cd3253fb85f94a753b62f3a4bbaa78b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 28 Oct 2026 19:29:09 +0000 Subject: [PATCH 065/109] docs: record upstream MVCP fix and expected RN release Co-authored-by: Codex --- patches/react-native/details.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index b474e801390b..6e9d603e1e20 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -14,10 +14,11 @@ 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. LegendList 3.6 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. - Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. -- Upstream PR/issue: https://github.com/react/react-native/pull/57959 covers z-index anchor selection. The follow-up guard preserving LegendList's positioned, zero-sized anchor is proposed in https://github.com/react/react-native/pull/57959#issuecomment-6341492122. +- Upstream PR/issue: https://github.com/react/react-native/pull/57959 merged on 2026-10-28 as [9bc1a28a9623419d62eb350acd0c172644e2c022](https://github.com/react/react-native/commit/9bc1a28a9623419d62eb350acd0c172644e2c022). It includes z-index anchor selection and the guard preserving LegendList's positioned, zero-sized anchor. +- Expected release: React Native 0.89.0, unless cherry-picked into an earlier release. The fix landed after the 0.88 branch cut and before the [scheduled 0.89 branch cut](https://reactnative.dev/releases/overview) on 2026-11-03. - Reproducer: https://github.com/react/react-native/pull/59360 adds the RNTester reproduction, without a runtime fix. The standalone reproduction and proposed anchor patch are in https://github.com/margelo/react-native-mvcp-reproducer. - Earlier proposal: https://github.com/react/react-native/pull/46247 closed without merging. -- Removal: Drop this patch only when the adopted React Native release preserves all three anchoring behaviors described above. +- Removal: Drop this patch only when the adopted React Native release preserves all three anchoring behaviors described above. The upstream commit still calculates the anchor before mounting, so its inclusion alone does not replace this patch's scroll-event timing change. - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) From a163c7a6b0635b1aae9520838631e1e09eea39a4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 28 Oct 2026 19:50:14 +0000 Subject: [PATCH 066/109] docs: clarify removal of upstreamed Android MVCP patch Co-authored-by: Codex --- patches/react-native/details.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 6e9d603e1e20..47d339370106 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -18,7 +18,7 @@ - Expected release: React Native 0.89.0, unless cherry-picked into an earlier release. The fix landed after the 0.88 branch cut and before the [scheduled 0.89 branch cut](https://reactnative.dev/releases/overview) on 2026-11-03. - Reproducer: https://github.com/react/react-native/pull/59360 adds the RNTester reproduction, without a runtime fix. The standalone reproduction and proposed anchor patch are in https://github.com/margelo/react-native-mvcp-reproducer. - Earlier proposal: https://github.com/react/react-native/pull/46247 closed without merging. -- Removal: Drop this patch only when the adopted React Native release preserves all three anchoring behaviors described above. The upstream commit still calculates the anchor before mounting, so its inclusion alone does not replace this patch's scroll-event timing change. +- Removal: Expected to be removable when the adopted React Native release contains `9bc1a28`. Before removing it, verify bidirectional pagination, changing row heights, and LegendList's positioned-anchor corrections with the patch disabled. Keep it on React Native 0.86 unless the upstream fix is backported. - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) From 1798cee54d785497aa45973661d3402aab95d604 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 29 Oct 2026 09:58:58 +0000 Subject: [PATCH 067/109] refactor: clarify newest-action scroll request names Name the requested scroll destination and explain the FlashList layout dependency. Co-authored-by: Codex --- .../MoneyRequestReportActionsList.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 0e0cb43e41a3..e637863461e0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -250,17 +250,18 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon fullPolicySnapshot: report?.policyID && fullPolicySnapshotLastModified ? {policyID: report.policyID, lastModified: fullPolicySnapshotLastModified} : undefined, }); - const [editScrollRequestID, setEditScrollRequestID] = useState(0); - const completedEditScrollRequestIDRef = useRef(0); + const [scrollToNewestRequestID, setScrollToNewestRequestID] = useState(0); + const lastHandledScrollToNewestRequestIDRef = useRef(0); const requestScrollToNewestAfterEdit = () => { // Force a layout commit even when closing the editor leaves the row's height unchanged. - setEditScrollRequestID((requestID) => requestID + 1); + setScrollToNewestRequestID((requestID) => requestID + 1); }; + // FlashList must measure the updated rows after the editor closes before scrolling to the newest action. const onCommitLayoutEffect = () => { - if (editScrollRequestID <= completedEditScrollRequestIDRef.current) { + if (scrollToNewestRequestID <= lastHandledScrollToNewestRequestIDRef.current) { return; } - completedEditScrollRequestIDRef.current = editScrollRequestID; + lastHandledScrollToNewestRequestIDRef.current = scrollToNewestRequestID; scrollToBottom(); }; From fde190a76f09ee558fc8f02d30a4258e3e5adb4d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 29 Oct 2026 10:07:35 +0000 Subject: [PATCH 068/109] test: use chronological indexes in badge-following fixture Keep the collapsed-target regression test aligned with the chronological report list. Co-authored-by: Codex --- tests/unit/useFollowActionBadgeTargetTest.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/unit/useFollowActionBadgeTargetTest.ts b/tests/unit/useFollowActionBadgeTargetTest.ts index 42a0750c160e..12772cc78196 100644 --- a/tests/unit/useFollowActionBadgeTargetTest.ts +++ b/tests/unit/useFollowActionBadgeTargetTest.ts @@ -40,11 +40,12 @@ describe('useFollowActionBadgeTarget', () => { }); it('follows a newer badge target when the previous target is hidden inside a collapsed run', () => { + // Given the previous target is represented by an older summary row in the chronological list. const scrollToActionBadgeTarget = jest.fn(); - const renderedVisibleReportActions = [makeAction('current'), makeAction('summary')]; + const renderedVisibleReportActions = [makeAction('summary'), makeAction('current')]; const reportActionIDToDisplayIndex = new Map([ - ['current', 0], - ['previous', 1], + ['previous', 0], + ['current', 1], ]); const {rerender} = renderHook( ({actionTargetReportActionID, actionBadgeTargetIndex}: {actionTargetReportActionID: string; actionBadgeTargetIndex: number}) => @@ -56,11 +57,13 @@ describe('useFollowActionBadgeTarget', () => { reportActionIDToDisplayIndex, scrollToActionBadgeTarget, }), - {initialProps: {actionTargetReportActionID: 'previous', actionBadgeTargetIndex: 1}}, + {initialProps: {actionTargetReportActionID: 'previous', actionBadgeTargetIndex: 0}}, ); - rerender({actionTargetReportActionID: 'current', actionBadgeTargetIndex: 0}); + // When the badge advances to a newer action outside the collapsed run. + rerender({actionTargetReportActionID: 'current', actionBadgeTargetIndex: 1}); + // Then the list follows the new target using the hidden action's mapped position. expect(scrollToActionBadgeTarget).toHaveBeenCalledTimes(1); }); }); From ddfe800851c9682342ae5069dd6ea43d04542993 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 29 Oct 2026 12:38:30 +0000 Subject: [PATCH 069/109] fix: reverse report actions without ES2023 array methods Older browsers lack Array.prototype.toReversed and the app has no polyfill for it, so copy and reverse the newest-first actions instead. Co-Authored-By: Claude Opus 5.5 --- 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 b36eca417f3a..651bcb7b75a9 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -409,7 +409,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const reportActionsToRender = shouldKeepInitialSnapshot ? (initialReportActionsSnapshot ?? renderedVisibleReportActions) : renderedVisibleReportActions; // Report actions are stored newest-first. LegendList renders them chronologically. - const chronologicalReportActions = reportActionsToRender.toReversed(); + const chronologicalReportActions = [...reportActionsToRender].reverse(); const shouldCollapseSystemMessages = isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isTransactionThread(parentReportAction); const { displayReportActions, From 5ecac4fa1baf2a21bea94f94f6d6e3d639c2c640 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 29 Oct 2026 12:38:31 +0000 Subject: [PATCH 070/109] docs: explain linked-message positioning effects Document why each effect in useLinkedReportActionScroll is needed and correct the LegendList version in the React Native MVCP patch notes. Co-Authored-By: Claude Opus 5.5 --- patches/react-native/details.md | 2 +- src/hooks/useLinkedReportActionScroll.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 9804a340a946..8234519d66e6 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -12,7 +12,7 @@ - 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. LegendList 3.6 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. + 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. LegendList 3.7 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. - Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. - Upstream PR/issue: https://github.com/react/react-native/pull/57959 merged on 2026-10-28 as [9bc1a28a9623419d62eb350acd0c172644e2c022](https://github.com/react/react-native/commit/9bc1a28a9623419d62eb350acd0c172644e2c022). It ports the original Android MVCP proposal and includes z-index anchor selection and support for LegendList's positioned, zero-sized anchor. - Expected release: React Native 0.89.0, [currently scheduled for 2026-12-07](https://reactnative.dev/releases/overview), unless cherry-picked into an earlier release. The fix landed after the 0.88 branch cut and before the scheduled 0.89 branch cut on 2026-11-03. diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index b8a77f24856d..ba8d5302fe86 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -36,6 +36,8 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, }; const stopPositioningFromEffect = useEffectEvent(stopPositioning); + // Each link and list mount starts a new positioning session. Refs keep this out of render, and the + // cleanup cancels a scroll that is still scheduled for the previous session. useEffect(() => { shouldPositionRef.current = !!linkedReportActionID; wasLastActionRef.current = false; @@ -79,6 +81,8 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, ); }); + // Positioning scrolls LegendList imperatively, so it runs here where it can be cancelled. The size and scroll + // handlers only bump measurementVersion, so a burst of measurements collapses into one scroll to the latest target. useEffect(() => { if (!isFocused || !linkedReportActionID || readyListID !== listID || targetIndex < 0 || !shouldPositionRef.current) { return; @@ -102,6 +106,8 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, return cancel; }, [isFocused, linkedReportActionID, listID, readyListID, targetIndex, isLastAction, measurementVersion]); + // On web, the reader can also scroll with the wheel, the scrollbar or the keyboard. Treat that input on the + // scroll element like a native drag, which hands the viewport back to the reader. useEffect(() => { if (Platform.OS !== 'web' || readyListID !== listID) { return; From 2ae796cacbd713514953f8cac121338149bdcda4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 29 Oct 2026 19:38:38 +0000 Subject: [PATCH 071/109] refactor: split report actions list into focused modules Co-authored-by: Codex --- src/pages/inbox/report/ReportActionsList.tsx | 995 ------------------ .../ReportActionsList/ReportActionsList.tsx | 30 + .../ReportActionsListContent.tsx | 466 ++++++++ .../inbox/report/ReportActionsList/index.ts | 3 + .../reportActionListUtils.ts | 66 ++ .../inbox/report/ReportActionsList/types.ts | 58 + .../useReportActionsListData.ts | 168 +++ .../useReportActionsListScroll.ts | 420 ++++++++ .../useReportActionsListTelemetry.ts | 25 + 9 files changed, 1236 insertions(+), 995 deletions(-) delete mode 100644 src/pages/inbox/report/ReportActionsList.tsx create mode 100644 src/pages/inbox/report/ReportActionsList/ReportActionsList.tsx create mode 100644 src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx create mode 100644 src/pages/inbox/report/ReportActionsList/index.ts create mode 100644 src/pages/inbox/report/ReportActionsList/reportActionListUtils.ts create mode 100644 src/pages/inbox/report/ReportActionsList/types.ts create mode 100644 src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts create mode 100644 src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts create mode 100644 src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx deleted file mode 100644 index 740e710b3077..000000000000 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ /dev/null @@ -1,995 +0,0 @@ -import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; -import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; -import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; - -import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; -import useEnvironment from '@hooks/useEnvironment'; -import useInitial from '@hooks/useInitial'; -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 {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; -import useReportActionsScroll from '@hooks/useReportActionsScroll'; -import useReportScrollManager from '@hooks/useReportScrollManager'; -import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; -import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; -import useSidePanelState from '@hooks/useSidePanelState'; -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 Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import { - getFirstVisibleReportActionID, - getFeedbackPromptActionIDs, - getReportActionMessage, - isConsecutiveActionMadeByPreviousActor, - isDeletedParentAction, - isNewerReportAction, - isReversedTransaction, - isTransactionThread, - shouldHidePayAction, -} from '@libs/ReportActionsUtils'; -import { - chatIncludesChronosWithID, - isArchivedNonExpenseReport, - isArchivedReport, - isCanceledTaskReport, - isExpenseReport, - isHarvestCreatedExpenseReport, - isInvoiceReport, - isIOUReport, - isResolvedSupportTicket, - isTaskReport, - isSupportTicket, - shouldShowMarkAsDone, -} from '@libs/ReportUtils'; -import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; - -import type {ReportsSplitNavigatorParamList} from '@navigation/types'; - -import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; -import type {ActionListRef} from '@pages/inbox/ActionListTypes'; -import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; -import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; - -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 getEmptyArray from '@src/types/utils/getEmptyArray'; - -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'; - -import {LegendList} from '@legendapp/list/react-native'; -import {useIsFocused, useRoute} from '@react-navigation/native'; -import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {accountIDSelector} from '@selectors/Session'; -import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {Platform, View} from 'react-native'; - -import AskConciergeWelcome from './AskConciergeWelcome'; -import CollapsedSystemMessages from './CollapsedSystemMessages'; -import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; -import FloatingMessageCounter from './FloatingMessageCounter'; -import {ReportActionPositionContextProvider} 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'; -import useReportActionsPresentation from './useReportActionsPresentation'; -import useScrollToEditingReportAction from './useScrollToEditingReportAction'; - -type ReportActionsListContentProps = { - /** The ID of the report to display actions for */ - reportID: string; - - conciergeChat: OnyxEntry; - onLayout?: (event: LayoutChangeEvent) => void; -}; - -type ReportActionsListProps = ReportActionsListContentProps; - -const PAGINATION_THRESHOLD = 0.75; -const REPORT_ACTIONS_DRAW_DISTANCE = 1500; - -const REPORT_ACTION_COMMENT_SIZE = { - SHORT: 'short', - MEDIUM: 'medium', - LONG: 'long', - EXTRA_LONG: 'extra-long', -} as const; - -// Character-count boundaries used to bucket comments so LegendList can recycle similarly sized items. -const REPORT_ACTION_COMMENT_SHORT_MAX_LENGTH = 80; -const REPORT_ACTION_COMMENT_MEDIUM_MAX_LENGTH = 320; -const REPORT_ACTION_COMMENT_LONG_MAX_LENGTH = 1200; - -function getReportActionCommentSize(messageLength: number): string { - if (messageLength <= REPORT_ACTION_COMMENT_SHORT_MAX_LENGTH) { - return REPORT_ACTION_COMMENT_SIZE.SHORT; - } - if (messageLength <= REPORT_ACTION_COMMENT_MEDIUM_MAX_LENGTH) { - return REPORT_ACTION_COMMENT_SIZE.MEDIUM; - } - if (messageLength <= REPORT_ACTION_COMMENT_LONG_MAX_LENGTH) { - 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. - * 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 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; - } - return item.reportActionID; -} - -/** - * Renders the report-actions list. Reads its data from `ReportActionsListStateContext` / `ReportActionsListActionsContext` and holds the - * UI-close hooks (`useUnreadMarker` / `useMarkAsRead` / `useReportActionsScroll`). `ReportActionsSkeletonGuard` - * mounts it only once content is ready, so those hooks never run while a skeleton shows. - */ -function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListContentProps) { - const isFocused = useIsFocused(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {isProduction} = useEnvironment(); - - const { - report, - hasOnceLoadedReportActions, - isInitialReportLoadPending, - isLoadingOlderReportActions, - hasLoadingOlderReportActionsError, - hasOlderActions, - hasNewerActions, - oldestReportActionID, - sortedAllReportActions, - oldestUnreadReportAction, - transactionThreadReport, - parentReportActionForTransactionThread, - treatAsNoPaginationAnchor, - parentReportAction, - sortedReportActions: unfilteredReportActions, - sortedVisibleReportActions: unfilteredVisibleReportActions, - isConciergeHiddenHistory, - showFullHistory, - hasPreviousMessages, - allReportActionIDs, - shouldShowConciergeWelcome, - isConciergeHistoryExpanded, - } = useReportActionsListState(); - - // Remove rows hidden by the renderer before calculating summary counts, headers, and scroll positions. - const sortedReportActions = report?.isWaitingOnBankAccount ? unfilteredReportActions.filter((action) => !shouldHidePayAction(action, true)) : unfilteredReportActions; - const sortedVisibleReportActions = report?.isWaitingOnBankAccount - ? unfilteredVisibleReportActions.filter((action) => !shouldHidePayAction(action, true)) - : unfilteredVisibleReportActions; - - const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats, handleShowPreviousMessages} = useReportActionsListActions(); - - const {isOffline} = useNetwork(); - const route = useRoute>(); - const reportActionIDFromRoute = route?.params?.reportActionID; - const {sessionStartTime} = useSidePanelState(); - const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); - - const lastRequestedOldestActionIDRef = useRef(undefined); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); - - useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); - - // Owned here rather than by the callout, which unmounts as the layout and composer change size. - useRetireMerchantRuleSuggestionOnLeave(reportID); - - // Clearing a link after sending returns to the live tail of the same list. Only opening a link - // needs fresh initial positioning, including reopening the same action after its link was cleared. - const [linkedActionListState, setLinkedActionListState] = useState({reportActionID: reportActionIDFromRoute, version: 0}); - if (linkedActionListState.reportActionID !== reportActionIDFromRoute) { - setLinkedActionListState({ - reportActionID: reportActionIDFromRoute, - version: linkedActionListState.version + (reportActionIDFromRoute ? 1 : 0), - }); - } - - // 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, - linkedActionListState.version, - hasOnceLoadedReportActions ? 'hydrated' : 'initial', - hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, - ].join(':'); - - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - const isReportArchived = !!isArchivedReport(reportNameValuePairs); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - - const reportAttributesSelector = (value: OnyxEntry) => { - const attrs = value?.reports?.[reportID]; - if (!attrs) { - return undefined; - } - return { - actionBadge: attrs.actionBadge, - actionTargetReportActionID: attrs.actionTargetReportActionID, - brickRoadStatus: attrs.brickRoadStatus, - }; - }; - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: reportAttributesSelector, - }); - const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - - const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); - const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); - const reportsParentHierarchy = useReportsParentHierarchy(reportID); - - const linkedReportActionID = reportActionIDFromRoute; - - const {getScrollOffset, scrollOffsetRef} = useActionListContext(); - const listRef = useActionListRef(); - const legendListRef = useRef(null); - - useImperativeHandle( - listRef, - (): ActionListRef => ({ - getScrollableNode: (): unknown => legendListRef.current?.getScrollableNode(), - getState: () => legendListRef.current?.getState(), - computeVisibleIndices: () => { - const state = legendListRef.current?.getState(); - return state ? {startIndex: state.start, endIndex: state.end} : undefined; - }, - 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(); - - const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; - - const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const shouldRestoreTailAfterHistoryRevealRef = useRef(false); - const oldestLoadedActionID = allReportActionIDs.at(-1); - const oldestLoadedActionIDAtHistoryRevealRef = useRef(undefined); - const distanceFromBottomRef = useRef(getScrollOffset()); - const shouldFollowEndOnResizeRef = useRef(false); - const onShowPreviousMessages = () => { - // Revealing older actions can make a short session scrollable before the requested history page arrives. - // Follow the tail only when the reader was actually there; the action-visible threshold is much wider. - shouldRestoreTailAfterHistoryRevealRef.current = distanceFromBottomRef.current <= 1 && !hasNewerActions; - oldestLoadedActionIDAtHistoryRevealRef.current = shouldRestoreTailAfterHistoryRevealRef.current && hasOlderActions ? oldestLoadedActionID : undefined; - if (shouldRestoreTailAfterHistoryRevealRef.current) { - shouldFollowEndOnResizeRef.current = true; - } - handleShowPreviousMessages(); - }; - - const {unreadMarkerReportActionID} = useUnreadMarker({ - reportID, - sortedVisibleReportActions, - sortedReportActions, - oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, - isScrolledOverThreshold: hasScrolledOverThreshold, - hasOnceLoadedReportActions: !!hasOnceLoadedReportActions, - newMessageBoundaryTime: isConciergeHiddenHistory ? sessionStartTime : undefined, - }); - - const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({ - reportID, - report, - transactionThreadReport, - sortedVisibleReportActions, - isScrolledToEnd: !hasScrolledOverThreshold, - hasNewerActions, - }); - - const persistedDraftReportAction = draftReportAction - ? (sortedAllReportActions ?? sortedVisibleReportActions).find( - (action) => action.reportActionID === draftReportAction.reportActionID && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - ) - : undefined; - - const shouldHideSupportTicketSurvey = isSupportTicket(report) && (!isResolvedSupportTicket(report) || !!reportNameValuePairs?.reopenedAsReportID); - const latestResolvedSupportTicketAction = sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED); - const latestSupportTicketSurveyAction = latestResolvedSupportTicketAction - ? sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && action.created >= latestResolvedSupportTicketAction.created) - : undefined; - - const visibleReportActions = (() => { - if (!draftReportAction) { - return sortedVisibleReportActions; - } - - if (showHiddenHistory && sessionStartTime && draftReportAction.created < sessionStartTime) { - return sortedVisibleReportActions; - } - - // A completed reveal belongs to the saved action, even when pagination keeps that action off screen. - if (persistedDraftReportAction && !isDraftPendingCompletion) { - return sortedVisibleReportActions; - } - - // Insert the synthetic draft into the already-descending render list without treating it as a persisted report action. - for (const [index, action] of sortedVisibleReportActions.entries()) { - if (action.reportActionID === draftReportAction.reportActionID) { - // Completed local replies can retain their pending add flag after the server merges followups. - if (!isDraftPendingCompletion) { - return sortedVisibleReportActions; - } - - const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; - visibleReportActionsWithDraft[index] = draftReportAction; - return visibleReportActionsWithDraft; - } - if (isNewerReportAction(draftReportAction, action)) { - const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; - visibleReportActionsWithDraft.splice(index, 0, draftReportAction); - return visibleReportActionsWithDraft; - } - } - - const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; - visibleReportActionsWithDraft.push(draftReportAction); - return visibleReportActionsWithDraft; - })(); - - const shouldFilterSupportTicketSurveys = - isSupportTicket(report) && - visibleReportActions.some( - (action) => - action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && - (shouldHideSupportTicketSurvey || action.reportActionID !== latestSupportTicketSurveyAction?.reportActionID), - ); - const renderedVisibleReportActions = shouldFilterSupportTicketSurveys - ? visibleReportActions.filter( - (action) => - action.actionName !== CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY || - (!shouldHideSupportTicketSurvey && action.reportActionID === latestSupportTicketSurveyAction?.reportActionID), - ) - : visibleReportActions; - - // 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. - const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); - const [hasReleasedInitialSnapshot, setHasReleasedInitialSnapshot] = useState(isOffline || !isInitialReportLoadPending || showFullHistory); - if (!hasReleasedInitialSnapshot && (isOffline || !isInitialReportLoadPending || showFullHistory)) { - setHasReleasedInitialSnapshot(true); - } - const shouldKeepInitialSnapshot = !hasReleasedInitialSnapshot; - const reportActionsToRender = shouldKeepInitialSnapshot ? (initialReportActionsSnapshot ?? renderedVisibleReportActions) : renderedVisibleReportActions; - - // Report actions are stored newest-first. LegendList renders them chronologically. - const chronologicalReportActions = [...reportActionsToRender].reverse(); - const shouldCollapseSystemMessages = isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isTransactionThread(parentReportAction); - const { - displayReportActions, - runsByAnchorReportActionID, - reportActionIDToDisplayIndex, - expandedSystemMessageReportActionIDs, - unreadMarkerReportActionIndex, - expandSystemMessageRun, - focusRequestedReportActionID, - onExpandedSystemMessageReady, - } = useReportActionsPresentation({ - visibleReportActions: shouldCollapseSystemMessages ? chronologicalReportActions : [], - linkedReportActionID, - unreadMarkerReportActionID, - }); - const listData = shouldCollapseSystemMessages ? displayReportActions : chronologicalReportActions; - const canonicalIndexByReportActionID = new Map(reportActionsToRender.map((action, index) => [action.reportActionID, index])); - - useEffect(() => { - if (!showFullHistory) { - shouldRestoreTailAfterHistoryRevealRef.current = false; - return; - } - if (!shouldRestoreTailAfterHistoryRevealRef.current) { - return; - } - const animationFrame = requestAnimationFrame(() => { - if (!shouldRestoreTailAfterHistoryRevealRef.current || !shouldFollowEndOnResizeRef.current) { - return; - } - legendListRef.current?.scrollToEnd({animated: false}); - const hasReceivedHistoryPage = oldestLoadedActionID !== oldestLoadedActionIDAtHistoryRevealRef.current || !hasOlderActions; - if (!oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError))) { - shouldRestoreTailAfterHistoryRevealRef.current = false; - } - }); - return () => cancelAnimationFrame(animationFrame); - }, [showFullHistory, listData.length, oldestLoadedActionID, hasOlderActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); - - const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; - const draftReportActionID = draftReportAction?.reportActionID; - const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; - - useEffect(() => { - if (!draftReportAction || isSyntheticDraftVisible) { - return; - } - - clearDraft(); - }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); - - useEffect(() => { - if (!isDraftPendingCompletion || !draftReportAction || !persistedDraftReportAction) { - return; - } - - // The persisted action is the durable completion signal when a terminal Pusher event is missed. - // Reconcile by action ID even when its HTML is byte-identical to the last streamed draft. - revealDraftFromReportAction(persistedDraftReportAction); - }, [draftReportAction, isDraftPendingCompletion, persistedDraftReportAction, revealDraftFromReportAction]); - - const reportScrollManager = useReportScrollManager(); - - // Which rows are on screen, tracked so an edit started on a message the user can already see doesn't move the list. - const viewableRowIndexesRef = useRef>(new Set()); - - const expandedFocusIndex = focusRequestedReportActionID ? (reportActionIDToDisplayIndex.get(focusRequestedReportActionID) ?? -1) : -1; - useEffect(() => { - if (expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex)) { - return; - } - - // An off-window revealed message must mount before it can hand off focus. - const frame = requestAnimationFrame(() => { - listRef.current?.scrollToIndex({index: expandedFocusIndex, animated: false, viewPosition: 0.5}); - }); - return () => cancelAnimationFrame(frame); - }, [expandedFocusIndex, focusRequestedReportActionID, listRef]); - - // The rendered indexes are known here, so scrolling the row into view mounts it and keeps the message visible while it's edited. - useScrollToEditingReportAction({ - visibleReportActions: listData, - scrollToIndex: (index) => { - // Already on screen, so its editor has mounted and taken focus — scrolling would only yank the user. - if (viewableRowIndexesRef.current.has(index)) { - return; - } - - reportScrollManager.scrollToIndex(index, {animated: false, viewPosition: 0.5}); - }, - }); - - // Find the index of the action badge target in the chronological data rendered by LegendList. - const actionBadgeTargetID = reportAttributes?.actionTargetReportActionID; - const actionBadgeTargetIndex = actionBadgeTargetID - ? (reportActionIDToDisplayIndex.get(actionBadgeTargetID) ?? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID)) - : -1; - const unreadMarkerListIndex = shouldCollapseSystemMessages ? unreadMarkerReportActionIndex : listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID); - - const { - trackVerticalScrolling, - onViewableItemsChanged, - isFloatingMessageCounterVisible, - isActionBadgeAboveViewport, - scrollToBottomAndMarkReportAsRead, - scrollToActionBadgeTarget, - shouldBeAlignedToTop, - initialScrollIndex, - initialScrollIndexParams, - onLoad, - onItemSizeChanged, - trackLinkedMessageScroll, - stopLinkedMessagePositioning, - } = useReportActionsScroll({ - listID, - reportID, - conciergeChat, - report, - transactionThreadReport, - parentReportAction, - sortedVisibleReportActions, - renderedVisibleReportActions: listData, - reportActionIDToDisplayIndex, - keyExtractor, - markNewestActionAsRead, - completeSkippedMarkAsRead, - unreadMarkerReportActionID, - unreadMarkerReportActionIndex: unreadMarkerListIndex, - hasNewerActions, - actionBadgeTargetIndex, - sortedAllReportActionsForPagination: sortedAllReportActions ?? [], - treatAsNoPaginationAnchor, - setTreatAsNoPaginationAnchor, - }); - - const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); - // Cover every initial LegendList layout, including cached chats, until the rows are positioned. - const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); - - useEffect(() => { - if (!isFocused || shouldShowInitialViewportSkeleton || loadedInitialViewportListID !== listID) { - return; - } - // onReady positions the list, but only this committed render has removed its cover. - // Refocus also completes new spans when the positioned list stays mounted. - markOpenReportEnd(reportID, report, {warm: true}); - }, [isFocused, listID, loadedInitialViewportListID, report, reportID, shouldShowInitialViewportSkeleton]); - - const readyEndListIDRef = useRef(undefined); - const lastScrollMetricsRef = useRef<{offset: number; contentHeight: number; viewportHeight: number} | undefined>(undefined); - const endCorrectionFrameRef = useRef(undefined); - const userScrolledListIDRef = useRef(undefined); - - const stopFollowingEnd = () => { - userScrolledListIDRef.current = listID; - shouldFollowEndOnResizeRef.current = false; - shouldRestoreTailAfterHistoryRevealRef.current = false; - if (endCorrectionFrameRef.current === undefined) { - return; - } - cancelAnimationFrame(endCorrectionFrameRef.current); - endCorrectionFrameRef.current = undefined; - }; - - const handleScrollBeginDrag = () => { - stopLinkedMessagePositioning(); - stopFollowingEnd(); - }; - - const scheduleEndCorrection = () => { - if (readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined) { - return; - } - endCorrectionFrameRef.current = requestAnimationFrame(() => { - endCorrectionFrameRef.current = undefined; - if (readyEndListIDRef.current === listID && shouldFollowEndOnResizeRef.current) { - legendListRef.current?.scrollToEnd({animated: false}); - } - }); - }; - - useEffect( - () => () => { - if (endCorrectionFrameRef.current === undefined) { - return; - } - cancelAnimationFrame(endCorrectionFrameRef.current); - endCorrectionFrameRef.current = undefined; - }, - [listID], - ); - - const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { - viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index)); - onViewableItemsChanged(info); - for (const item of info.changed) { - if (!item.isViewable) { - continue; - } - allowLegendListItemOverflow(legendListRef.current, item.index); - } - }; - - const handleListReady = () => { - // onLoad only means rows rendered; keep their estimated positions covered until initial scrolling settles. - onLoad(); - setLoadedInitialViewportListID(listID); - readyEndListIDRef.current = listID; - // An unread marker near the newest action lands at the end. Rows above it are still measured after - // onReady, so keep that end in view instead of exposing older messages. - const isUnreadInitialScroll = initialScrollIndex !== undefined && !reportActionIDFromRoute && !shouldBeAlignedToTop; - const didLandAtEnd = initialScrollIndex === undefined || (isUnreadInitialScroll && !!legendListRef.current?.getState()?.isAtEnd); - shouldFollowEndOnResizeRef.current = didLandAtEnd && !hasNewerActions && userScrolledListIDRef.current !== listID; - lastScrollMetricsRef.current = undefined; - // The final row can grow after LegendList's initial end estimate (for example, Concierge feedback). - scheduleEndCorrection(); - }; - - const loadOlderChatsOnStartReached = () => { - if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { - return; - } - - lastRequestedOldestActionIDRef.current = oldestReportActionID; - loadOlderChats(false); - }; - - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackLinkedMessageScroll(event); - const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; - const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); - distanceFromBottomRef.current = distanceFromBottom; - const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; - const previousMetrics = lastScrollMetricsRef.current; - if (readyEndListIDRef.current === listID && !hasNewerActions) { - if (distanceFromBottom < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD) { - shouldFollowEndOnResizeRef.current = true; - } else if ( - (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || - (previousMetrics && - previousMetrics.contentHeight === contentSize.height && - previousMetrics.viewportHeight === layoutMeasurement.height && - endCorrectionFrameRef.current === undefined) - ) { - // A pending history request must not reclaim the end after the reader has moved away. - stopFollowingEnd(); - } - } - lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; - - if (isNearStart) { - loadOlderChatsOnStartReached(); - } else { - lastRequestedOldestActionIDRef.current = undefined; - } - - const bottomRelativeEvent = { - ...event, - nativeEvent: { - ...event.nativeEvent, - contentOffset: {...contentOffset, y: distanceFromBottom}, - }, - }; - - trackVerticalScrolling(bottomRelativeEvent); - setHasScrolledOverThreshold(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - emitComposerScrollEvents(); - }; - - const loadNewerChatsAfterTransitions = () => { - if (!isSearchTopmostFullScreenRoute()) { - loadNewerChats(false); - return; - } - - TransitionTracker.runAfterTransitions({ - callback: () => { - requestAnimationFrame(() => loadNewerChats(false)); - }, - }); - }; - - const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); - - const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - - // An agent account can be opened through Copilot, and the agent must not rate its own replies - const actorAccountIDs = [...new Set(renderedVisibleReportActions.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); - const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); - - // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet - const latestConciergeFeedbackActionIDs = - reportNameValuePairs?.conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions - ? getEmptyArray() - : getFeedbackPromptActionIDs(renderedVisibleReportActions, allReportActionIDs, customAgentAccountIDs); - - useFollowActionBadgeTarget({ - reportID, - actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, - actionBadgeTargetIndex, - renderedVisibleReportActions: listData, - reportActionIDToDisplayIndex, - scrollToActionBadgeTarget, - }); - - /** - * Thread's divider line should hide when the first chat in the thread is marked as unread. - * This is so that it will not be conflicting with header's separator line. - */ - const shouldHideThreadDividerLine = firstVisibleReportActionID === unreadMarkerReportActionID; - - const shouldUseThreadDividerLine = (() => { - const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; - - if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { - return false; - } - - if (isTransactionThread(parentReportAction)) { - return !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction); - } - - if (isTaskReport(report)) { - return !isCanceledTaskReport(report, parentReportAction); - } - - if (isSupportTicket(report)) { - return true; - } - - return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); - })(); - - const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { - const canonicalIndex = canonicalIndexByReportActionID.get(reportAction.reportActionID) ?? index; - const systemMessageRun = runsByAnchorReportActionID.get(reportAction.reportActionID); - const previousReportAction = index > 0 ? listData.at(index - 1) : undefined; - const previousRun = previousReportAction ? runsByAnchorReportActionID.get(previousReportAction.reportActionID) : undefined; - const isAfterCollapsedRun = !!previousRun && !previousRun.isExpanded; - const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; - - return ( - - {systemMessageRun && !systemMessageRun.isExpanded ? ( - expandSystemMessageRun(systemMessageRun.reportActionIDs)} - unreadMarkerReportActionID={ - unreadMarkerReportActionID && - systemMessageRun.reportActionIDs.includes(unreadMarkerReportActionID) && - (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) - ? unreadMarkerReportActionID - : undefined - } - /> - ) : ( - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} - reportsParentHierarchy={reportsParentHierarchy} - onExpandedSystemMessageReady={ - reportAction.reportActionID === focusRequestedReportActionID ? (target) => onExpandedSystemMessageReady(reportAction.reportActionID, target) : undefined - } - shouldUseThreadDividerLine={shouldUseThreadDividerLine} - isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - /> - )} - {!!reportStable?.reportID && ( - - )} - - ); - }; - - // Ensure row presentation updates when these values change without changing the action data. - const extraData = [ - unreadMarkerReportActionID, - isArchivedNonExpenseReport(report, isReportArchived), - draftReportActionID, - draftMessageHTML, - isDraftPendingCompletion, - latestConciergeFeedbackActionIDs, - expandedSystemMessageReportActionIDs, - focusRequestedReportActionID, - ]; - - const latestActionsFooter = ( - <> - {isConciergeHistoryExpanded && ( - { - collapseConciergeHistory(); - if (reportActionIDFromRoute) { - Navigation.setParams({reportActionID: ''}, route.key); - } - }} - /> - )} - - - ); - - const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - - const offlineHistoryHeader = shouldShowOfflineSkeleton ? : undefined; - - const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ - policy, - report, - isTrackIntentUser, - rules, - }); - - /** - * Runs when the FlatList finishes laying out - */ - const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { - onLayout?.(event); - const state = legendListRef.current?.getState(); - if (state && readyEndListIDRef.current === listID) { - const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); - scrollOffsetRef.current = distanceFromBottom; - distanceFromBottomRef.current = distanceFromBottom; - } - scheduleEndCorrection(); - }; - - // The guard only mounts this content when the report is loaded, so this is effectively unreachable. - // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report - // is cleared mid-session while the latch keeps the content mounted. - if (!report) { - return ; - } - - if (shouldShowConciergeWelcome) { - return ( - - ); - } - - return ( - <> - {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view - does not carry it out of sight. Renders nothing on the layouts the composer mount serves. */} - - - - { - trackVerticalScrolling(undefined); - scheduleEndCorrection(); - }} - /> - {shouldShowInitialViewportSkeleton && ( - - - - )} - - - ); -} - -/** - * Public report-actions list. Thin composition that wraps the content in `ReportActionsSkeletonGuard`, - * which owns the data pipeline + skeleton decision and only mounts the content once it is ready. - */ -function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsListProps) { - return ( - - - - ); -} - -export default ReportActionsList; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsList.tsx new file mode 100644 index 000000000000..41f5f8ac67f1 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsList.tsx @@ -0,0 +1,30 @@ +import ReportActionsSkeletonGuard from '@pages/inbox/report/ReportActionsSkeletonGuard'; + +/** Keeps the report-data readiness guard outside the scrollable message list. */ +import React from 'react'; + +import type {ReportActionsListProps} from './types'; + +import ReportActionsListContent from './ReportActionsListContent'; + +/** + * Public report-actions list. Thin composition that wraps the content in `ReportActionsSkeletonGuard`, + * which owns the data pipeline + skeleton decision and only mounts the content once it is ready. + */ +function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsListProps) { + return ( + + + + ); +} + +export default ReportActionsList; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx new file mode 100644 index 000000000000..13edd65bee8e --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -0,0 +1,466 @@ +/** Renders report messages with history controls, unread indicators, and loading states. */ +import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; +import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; +import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +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 {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; +import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; +import useSidePanelState from '@hooks/useSidePanelState'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUnreadMarker from '@hooks/useUnreadMarker'; + +import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import { + getFirstVisibleReportActionID, + getFeedbackPromptActionIDs, + isConsecutiveActionMadeByPreviousActor, + isDeletedParentAction, + isReversedTransaction, + isTransactionThread, + shouldHidePayAction, +} from '@libs/ReportActionsUtils'; +import { + chatIncludesChronosWithID, + isArchivedNonExpenseReport, + isArchivedReport, + isCanceledTaskReport, + isExpenseReport, + isHarvestCreatedExpenseReport, + isInvoiceReport, + isIOUReport, + isTaskReport, + isSupportTicket, + shouldShowMarkAsDone, +} from '@libs/ReportUtils'; + +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; +import AskConciergeWelcome from '@pages/inbox/report/AskConciergeWelcome'; +import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; +import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; +import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; +import {ReportActionPositionContextProvider} from '@pages/inbox/report/ReportActionIndexContext'; +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import ReportActionsListHeader from '@pages/inbox/report/ReportActionsListHeader'; +import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; +import ReportActionsListPaddingView from '@pages/inbox/report/ReportActionsListPaddingView'; +import ShowPreviousMessagesButton from '@pages/inbox/report/ShowPreviousMessagesButton'; + +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 getEmptyArray from '@src/types/utils/getEmptyArray'; + +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {LegendList} from '@legendapp/list/react-native'; +import {useRoute} from '@react-navigation/native'; +import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {accountIDSelector} from '@selectors/Session'; +import React, {useState} from 'react'; +import {Platform, View} from 'react-native'; + +import type {ReportActionsListProps} from './types'; + +import useReportActionsListData from './useReportActionsListData'; +import useReportActionsListScroll from './useReportActionsListScroll'; +import useReportActionsListTelemetry from './useReportActionsListTelemetry'; + +const REPORT_ACTIONS_DRAW_DISTANCE = 1500; + +function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isProduction} = useEnvironment(); + + const { + report, + hasOnceLoadedReportActions, + hasNewerActions, + oldestUnreadReportAction, + transactionThreadReport, + parentReportActionForTransactionThread, + parentReportAction, + sortedReportActions: unfilteredReportActions, + sortedVisibleReportActions: unfilteredVisibleReportActions, + isConciergeHiddenHistory, + showFullHistory, + hasPreviousMessages, + allReportActionIDs, + shouldShowConciergeWelcome, + isConciergeHistoryExpanded, + } = useReportActionsListState(); + + // Remove rows hidden by the renderer before calculating summary counts, headers, and scroll positions. + const sortedReportActions = report?.isWaitingOnBankAccount ? unfilteredReportActions.filter((action) => !shouldHidePayAction(action, true)) : unfilteredReportActions; + const sortedVisibleReportActions = report?.isWaitingOnBankAccount + ? unfilteredVisibleReportActions.filter((action) => !shouldHidePayAction(action, true)) + : unfilteredVisibleReportActions; + + const {isOffline} = useNetwork(); + const route = useRoute>(); + const reportActionIDFromRoute = route?.params?.reportActionID; + const {sessionStartTime} = useSidePanelState(); + const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); + + useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); + + // Owned here rather than by the callout, which unmounts as the layout and composer change size. + useRetireMerchantRuleSuggestionOnLeave(reportID); + + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); + const isReportArchived = !!isArchivedReport(reportNameValuePairs); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + + const reportAttributesSelector = (value: OnyxEntry) => { + const attrs = value?.reports?.[reportID]; + if (!attrs) { + return undefined; + } + return { + actionBadge: attrs.actionBadge, + actionTargetReportActionID: attrs.actionTargetReportActionID, + brickRoadStatus: attrs.brickRoadStatus, + }; + }; + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: reportAttributesSelector, + }); + const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); + + const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); + const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); + + const linkedReportActionID = reportActionIDFromRoute; + + const {getScrollOffset} = useActionListContext(); + + const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + + const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const {unreadMarkerReportActionID} = useUnreadMarker({ + reportID, + sortedVisibleReportActions, + sortedReportActions, + oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, + isScrolledOverThreshold: hasScrolledOverThreshold, + hasOnceLoadedReportActions: !!hasOnceLoadedReportActions, + newMessageBoundaryTime: isConciergeHiddenHistory ? sessionStartTime : undefined, + }); + + const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({ + reportID, + report, + transactionThreadReport, + sortedVisibleReportActions, + isScrolledToEnd: !hasScrolledOverThreshold, + hasNewerActions, + }); + + const { + renderedVisibleReportActions, + reportActionsToRender, + listData, + canonicalIndexByReportActionID, + runsByAnchorReportActionID, + reportActionIDToDisplayIndex, + expandedSystemMessageReportActionIDs, + unreadMarkerListIndex, + expandSystemMessageRun, + focusRequestedReportActionID, + onExpandedSystemMessageReady, + draftMessageHTML, + draftReportActionID, + isDraftPendingCompletion, + } = useReportActionsListData({ + sortedVisibleReportActions, + reportNameValuePairs, + isOffline, + sessionStartTime, + linkedReportActionID, + unreadMarkerReportActionID, + }); + + const { + listID, + legendListProps, + loadedInitialViewportListID, + shouldShowInitialViewportSkeleton, + shouldBeAlignedToTop, + onShowPreviousMessages, + isFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + scrollToBottomAndMarkReportAsRead, + scrollToActionBadgeTarget, + } = useReportActionsListScroll({ + reportID, + conciergeChat, + sortedVisibleReportActions, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + markNewestActionAsRead, + completeSkippedMarkAsRead, + unreadMarkerReportActionID, + unreadMarkerReportActionIndex: unreadMarkerListIndex, + focusRequestedReportActionID, + actionBadgeTargetID: reportAttributes?.actionTargetReportActionID, + isOffline, + linkedReportActionID, + onScrolledOverThresholdChange: setHasScrolledOverThreshold, + onLayout, + }); + + useReportActionsListTelemetry({reportID, listID, loadedInitialViewportListID, shouldShowInitialViewportSkeleton}); + + const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); + + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + + // An agent account can be opened through Copilot, and the agent must not rate its own replies + const actorAccountIDs = [...new Set(renderedVisibleReportActions.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); + const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); + + // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet + const latestConciergeFeedbackActionIDs = + reportNameValuePairs?.conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions + ? getEmptyArray() + : getFeedbackPromptActionIDs(renderedVisibleReportActions, allReportActionIDs, customAgentAccountIDs); + + /** + * Thread's divider line should hide when the first chat in the thread is marked as unread. + * This is so that it will not be conflicting with header's separator line. + */ + const shouldHideThreadDividerLine = firstVisibleReportActionID === unreadMarkerReportActionID; + + const shouldUseThreadDividerLine = (() => { + const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; + + if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + return false; + } + + if (isTransactionThread(parentReportAction)) { + return !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction); + } + + if (isTaskReport(report)) { + return !isCanceledTaskReport(report, parentReportAction); + } + + if (isSupportTicket(report)) { + return true; + } + + return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); + })(); + + const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { + const canonicalIndex = canonicalIndexByReportActionID.get(reportAction.reportActionID) ?? index; + const systemMessageRun = runsByAnchorReportActionID.get(reportAction.reportActionID); + const previousReportAction = index > 0 ? listData.at(index - 1) : undefined; + const previousRun = previousReportAction ? runsByAnchorReportActionID.get(previousReportAction.reportActionID) : undefined; + const isAfterCollapsedRun = !!previousRun && !previousRun.isExpanded; + const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + + return ( + + {systemMessageRun && !systemMessageRun.isExpanded ? ( + expandSystemMessageRun(systemMessageRun.reportActionIDs)} + unreadMarkerReportActionID={ + unreadMarkerReportActionID && + systemMessageRun.reportActionIDs.includes(unreadMarkerReportActionID) && + (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) + ? unreadMarkerReportActionID + : undefined + } + /> + ) : ( + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} + reportsParentHierarchy={reportsParentHierarchy} + onExpandedSystemMessageReady={ + reportAction.reportActionID === focusRequestedReportActionID ? (target) => onExpandedSystemMessageReady(reportAction.reportActionID, target) : undefined + } + shouldUseThreadDividerLine={shouldUseThreadDividerLine} + isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + )} + {!!reportStable?.reportID && ( + + )} + + ); + }; + + // Ensure row presentation updates when these values change without changing the action data. + const extraData = [ + unreadMarkerReportActionID, + isArchivedNonExpenseReport(report, isReportArchived), + draftReportActionID, + draftMessageHTML, + isDraftPendingCompletion, + latestConciergeFeedbackActionIDs, + expandedSystemMessageReportActionIDs, + focusRequestedReportActionID, + ]; + + const latestActionsFooter = ( + <> + {isConciergeHistoryExpanded && ( + { + collapseConciergeHistory(); + if (reportActionIDFromRoute) { + Navigation.setParams({reportActionID: ''}, route.key); + } + }} + /> + )} + + + ); + + const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + + const offlineHistoryHeader = shouldShowOfflineSkeleton ? : undefined; + + const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ + policy, + report, + isTrackIntentUser, + rules, + }); + + // The guard only mounts this content when the report is loaded, so this is effectively unreachable. + // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report + // is cleared mid-session while the latch keeps the content mounted. + if (!report) { + return ; + } + + if (shouldShowConciergeWelcome) { + return ( + + ); + } + + return ( + <> + {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view + does not carry it out of sight. Renders nothing on the layouts the composer mount serves. */} + + + + + {shouldShowInitialViewportSkeleton && ( + + + + )} + + + ); +} + +export default ReportActionsListContent; diff --git a/src/pages/inbox/report/ReportActionsList/index.ts b/src/pages/inbox/report/ReportActionsList/index.ts new file mode 100644 index 000000000000..39d5e81bb840 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/index.ts @@ -0,0 +1,3 @@ +import ReportActionsList from './ReportActionsList'; + +export default ReportActionsList; diff --git a/src/pages/inbox/report/ReportActionsList/reportActionListUtils.ts b/src/pages/inbox/report/ReportActionsList/reportActionListUtils.ts new file mode 100644 index 000000000000..b098bf98448d --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/reportActionListUtils.ts @@ -0,0 +1,66 @@ +/** Stable row keys and measurement groups let LegendList recycle report actions without remounting them. */ +import {getReportActionMessage} from '@libs/ReportActionsUtils'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +const REPORT_ACTION_COMMENT_SIZE = { + SHORT: 'short', + MEDIUM: 'medium', + LONG: 'long', + EXTRA_LONG: 'extra-long', +} as const; + +// Character-count boundaries used to bucket comments so LegendList can recycle similarly sized items. +const REPORT_ACTION_COMMENT_SHORT_MAX_LENGTH = 80; +const REPORT_ACTION_COMMENT_MEDIUM_MAX_LENGTH = 320; +const REPORT_ACTION_COMMENT_LONG_MAX_LENGTH = 1200; + +function getReportActionItemType(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. + * We use the reportActionID that is a string representation of a random 64-bit int, which should be + * random enough to avoid collisions + */ +function getReportActionKey(item: OnyxTypes.ReportAction): string { + // 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; + } + return item.reportActionID; +} + +function getReportActionCommentSize(messageLength: number): string { + if (messageLength <= REPORT_ACTION_COMMENT_SHORT_MAX_LENGTH) { + return REPORT_ACTION_COMMENT_SIZE.SHORT; + } + if (messageLength <= REPORT_ACTION_COMMENT_MEDIUM_MAX_LENGTH) { + return REPORT_ACTION_COMMENT_SIZE.MEDIUM; + } + if (messageLength <= REPORT_ACTION_COMMENT_LONG_MAX_LENGTH) { + return REPORT_ACTION_COMMENT_SIZE.LONG; + } + return REPORT_ACTION_COMMENT_SIZE.EXTRA_LONG; +} + +export {getReportActionItemType, getReportActionKey}; diff --git a/src/pages/inbox/report/ReportActionsList/types.ts b/src/pages/inbox/report/ReportActionsList/types.ts new file mode 100644 index 000000000000..7198e1af6aab --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/types.ts @@ -0,0 +1,58 @@ +import type useReportActionsScroll from '@hooks/useReportActionsScroll'; + +import type useReportActionsPresentation from '@pages/inbox/report/useReportActionsPresentation'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import type {LayoutChangeEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +type ReportActionsListProps = { + /** The ID of the report to display actions for */ + reportID: string; + + conciergeChat: OnyxEntry; + onLayout?: (event: LayoutChangeEvent) => void; +}; + +type UseReportActionsListDataParams = { + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + reportNameValuePairs: OnyxEntry; + isOffline: boolean; + sessionStartTime: string | null; + linkedReportActionID: string | undefined; + unreadMarkerReportActionID: string | null; +}; + +type UseReportActionsListScrollParams = Pick< + Parameters[0], + | 'reportID' + | 'conciergeChat' + | 'sortedVisibleReportActions' + | 'renderedVisibleReportActions' + | 'markNewestActionAsRead' + | 'completeSkippedMarkAsRead' + | 'unreadMarkerReportActionID' + | 'unreadMarkerReportActionIndex' +> & + Pick, 'reportActionIDToDisplayIndex' | 'focusRequestedReportActionID'> & + Pick & + Pick & { + actionBadgeTargetID: string | undefined; + onScrolledOverThresholdChange: (isOverThreshold: boolean) => void; + }; + +type UseReportActionsListTelemetryParams = { + reportID: string; + listID: string; + loadedInitialViewportListID: string | undefined; + shouldShowInitialViewportSkeleton: boolean; +}; + +type ReportActionsListScrollMetrics = { + offset: number; + contentHeight: number; + viewportHeight: number; +}; + +export type {ReportActionsListProps, UseReportActionsListDataParams, UseReportActionsListScrollParams, UseReportActionsListTelemetryParams, ReportActionsListScrollMetrics}; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts new file mode 100644 index 000000000000..a4ee0e069020 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts @@ -0,0 +1,168 @@ +/** Prepares chronological report rows, including streamed drafts, initial snapshots, and collapsed system messages. */ +import useInitial from '@hooks/useInitial'; + +import {getReportActionMessage, isNewerReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; +import {isExpenseReport, isIOUReport, isInvoiceReport, isResolvedSupportTicket, isSupportTicket} from '@libs/ReportUtils'; + +import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import useReportActionsPresentation from '@pages/inbox/report/useReportActionsPresentation'; + +import CONST from '@src/CONST'; + +import {useEffect, useState} from 'react'; + +import type {UseReportActionsListDataParams} from './types'; + +function useReportActionsListData({ + sortedVisibleReportActions, + reportNameValuePairs, + isOffline, + sessionStartTime, + linkedReportActionID, + unreadMarkerReportActionID, +}: UseReportActionsListDataParams) { + const {report, sortedAllReportActions, parentReportAction, isInitialReportLoadPending, isConciergeHiddenHistory, showFullHistory} = useReportActionsListState(); + const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); + const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); + const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + + const persistedDraftReportAction = draftReportAction + ? (sortedAllReportActions ?? sortedVisibleReportActions).find( + (action) => action.reportActionID === draftReportAction.reportActionID && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + ) + : undefined; + + const shouldHideSupportTicketSurvey = isSupportTicket(report) && (!isResolvedSupportTicket(report) || !!reportNameValuePairs?.reopenedAsReportID); + const latestResolvedSupportTicketAction = sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED); + const latestSupportTicketSurveyAction = latestResolvedSupportTicketAction + ? sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && action.created >= latestResolvedSupportTicketAction.created) + : undefined; + + const visibleReportActions = (() => { + if (!draftReportAction) { + return sortedVisibleReportActions; + } + + if (showHiddenHistory && sessionStartTime && draftReportAction.created < sessionStartTime) { + return sortedVisibleReportActions; + } + + // A completed reveal belongs to the saved action, even when pagination keeps that action off screen. + if (persistedDraftReportAction && !isDraftPendingCompletion) { + return sortedVisibleReportActions; + } + + // Insert the synthetic draft into the already-descending render list without treating it as a persisted report action. + for (const [index, action] of sortedVisibleReportActions.entries()) { + if (action.reportActionID === draftReportAction.reportActionID) { + // Completed local replies can retain their pending add flag after the server merges followups. + if (!isDraftPendingCompletion) { + return sortedVisibleReportActions; + } + + const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; + visibleReportActionsWithDraft[index] = draftReportAction; + return visibleReportActionsWithDraft; + } + if (isNewerReportAction(draftReportAction, action)) { + const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; + visibleReportActionsWithDraft.splice(index, 0, draftReportAction); + return visibleReportActionsWithDraft; + } + } + + const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; + visibleReportActionsWithDraft.push(draftReportAction); + return visibleReportActionsWithDraft; + })(); + + const shouldFilterSupportTicketSurveys = + isSupportTicket(report) && + visibleReportActions.some( + (action) => + action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && + (shouldHideSupportTicketSurvey || action.reportActionID !== latestSupportTicketSurveyAction?.reportActionID), + ); + const renderedVisibleReportActions = shouldFilterSupportTicketSurveys + ? visibleReportActions.filter( + (action) => + action.actionName !== CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY || + (!shouldHideSupportTicketSurvey && action.reportActionID === latestSupportTicketSurveyAction?.reportActionID), + ) + : visibleReportActions; + + // 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. + const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); + const [hasReleasedInitialSnapshot, setHasReleasedInitialSnapshot] = useState(isOffline || !isInitialReportLoadPending || showFullHistory); + if (!hasReleasedInitialSnapshot && (isOffline || !isInitialReportLoadPending || showFullHistory)) { + setHasReleasedInitialSnapshot(true); + } + const shouldKeepInitialSnapshot = !hasReleasedInitialSnapshot; + const reportActionsToRender = shouldKeepInitialSnapshot ? (initialReportActionsSnapshot ?? renderedVisibleReportActions) : renderedVisibleReportActions; + + // Report actions are stored newest-first. LegendList renders them chronologically. + const chronologicalReportActions = [...reportActionsToRender].reverse(); + const shouldCollapseSystemMessages = isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isTransactionThread(parentReportAction); + const { + displayReportActions, + runsByAnchorReportActionID, + reportActionIDToDisplayIndex, + expandedSystemMessageReportActionIDs, + unreadMarkerReportActionIndex, + expandSystemMessageRun, + focusRequestedReportActionID, + onExpandedSystemMessageReady, + } = useReportActionsPresentation({ + visibleReportActions: shouldCollapseSystemMessages ? chronologicalReportActions : [], + linkedReportActionID, + unreadMarkerReportActionID, + }); + const listData = shouldCollapseSystemMessages ? displayReportActions : chronologicalReportActions; + const canonicalIndexByReportActionID = new Map(reportActionsToRender.map((action, index) => [action.reportActionID, index])); + + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; + const draftReportActionID = draftReportAction?.reportActionID; + const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; + + useEffect(() => { + if (!draftReportAction || isSyntheticDraftVisible) { + return; + } + + clearDraft(); + }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); + + useEffect(() => { + if (!isDraftPendingCompletion || !draftReportAction || !persistedDraftReportAction) { + return; + } + + // The persisted action is the durable completion signal when a terminal Pusher event is missed. + // Reconcile by action ID even when its HTML is byte-identical to the last streamed draft. + revealDraftFromReportAction(persistedDraftReportAction); + }, [draftReportAction, isDraftPendingCompletion, persistedDraftReportAction, revealDraftFromReportAction]); + + const unreadMarkerListIndex = shouldCollapseSystemMessages ? unreadMarkerReportActionIndex : listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID); + + return { + renderedVisibleReportActions, + reportActionsToRender, + listData, + canonicalIndexByReportActionID, + runsByAnchorReportActionID, + reportActionIDToDisplayIndex, + expandedSystemMessageReportActionIDs, + unreadMarkerListIndex, + expandSystemMessageRun, + focusRequestedReportActionID, + onExpandedSystemMessageReady, + draftMessageHTML, + draftReportActionID, + isDraftPendingCompletion, + }; +} + +export default useReportActionsListData; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts new file mode 100644 index 000000000000..e9a2b4deaf44 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -0,0 +1,420 @@ +/** Owns LegendList positioning, end following, pagination, and scroll events for report actions. */ +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; + +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; +import useReportActionsScroll from '@hooks/useReportActionsScroll'; +import useReportScrollManager from '@hooks/useReportScrollManager'; + +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; +import type {ActionListRef} from '@pages/inbox/ActionListTypes'; +import {useReportActionsListActions, useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import useFollowActionBadgeTarget from '@pages/inbox/report/useFollowActionBadgeTarget'; +import useScrollToEditingReportAction from '@pages/inbox/report/useScrollToEditingReportAction'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {LegendListRef, OnViewableItemsChangedInfo} from '@legendapp/list/react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; + +import {useEffect, useImperativeHandle, useRef, useState} from 'react'; + +import type {ReportActionsListScrollMetrics, UseReportActionsListScrollParams} from './types'; + +import {getReportActionItemType, getReportActionKey} from './reportActionListUtils'; + +const PAGINATION_THRESHOLD = 0.75; + +function useReportActionsListScroll({ + reportID, + conciergeChat, + sortedVisibleReportActions, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + markNewestActionAsRead, + completeSkippedMarkAsRead, + unreadMarkerReportActionID, + unreadMarkerReportActionIndex, + focusRequestedReportActionID, + actionBadgeTargetID, + isOffline, + linkedReportActionID: reportActionIDFromRoute, + onScrolledOverThresholdChange, + onLayout, +}: UseReportActionsListScrollParams) { + const { + report, + hasOnceLoadedReportActions, + isInitialReportLoadPending, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + hasOlderActions, + hasNewerActions, + oldestReportActionID, + sortedAllReportActions, + oldestUnreadReportAction, + transactionThreadReport, + parentReportAction, + treatAsNoPaginationAnchor, + isConciergeHiddenHistory, + showFullHistory, + allReportActionIDs, + } = useReportActionsListState(); + const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats, handleShowPreviousMessages} = useReportActionsListActions(); + const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + const lastRequestedOldestActionIDRef = useRef(undefined); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); + + // Clearing a link after sending returns to the live tail of the same list. Only opening a link + // needs fresh initial positioning, including reopening the same action after its link was cleared. + const [linkedActionListState, setLinkedActionListState] = useState({reportActionID: reportActionIDFromRoute, version: 0}); + if (linkedActionListState.reportActionID !== reportActionIDFromRoute) { + setLinkedActionListState({ + reportActionID: reportActionIDFromRoute, + version: linkedActionListState.version + (reportActionIDFromRoute ? 1 : 0), + }); + } + + // 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, + linkedActionListState.version, + hasOnceLoadedReportActions ? 'hydrated' : 'initial', + hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, + ].join(':'); + + const {getScrollOffset, scrollOffsetRef} = useActionListContext(); + const listRef = useActionListRef(); + const legendListRef = useRef(null); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + getScrollableNode: (): unknown => legendListRef.current?.getScrollableNode(), + getState: () => legendListRef.current?.getState(), + computeVisibleIndices: () => { + const state = legendListRef.current?.getState(); + return state ? {startIndex: state.start, endIndex: state.end} : undefined; + }, + scrollToEnd: (options) => { + legendListRef.current?.scrollToEnd(options); + }, + scrollToIndex: (options) => { + legendListRef.current?.scrollToIndex(options); + }, + scrollToOffset: (options) => { + legendListRef.current?.scrollToOffset(options); + }, + }), + [], + ); + + const shouldRestoreTailAfterHistoryRevealRef = useRef(false); + const oldestLoadedActionID = allReportActionIDs.at(-1); + const oldestLoadedActionIDAtHistoryRevealRef = useRef(undefined); + const distanceFromBottomRef = useRef(getScrollOffset()); + const shouldFollowEndOnResizeRef = useRef(false); + const onShowPreviousMessages = () => { + // Revealing older actions can make a short session scrollable before the requested history page arrives. + // Follow the tail only when the reader was actually there; the action-visible threshold is much wider. + shouldRestoreTailAfterHistoryRevealRef.current = distanceFromBottomRef.current <= 1 && !hasNewerActions; + oldestLoadedActionIDAtHistoryRevealRef.current = shouldRestoreTailAfterHistoryRevealRef.current && hasOlderActions ? oldestLoadedActionID : undefined; + if (shouldRestoreTailAfterHistoryRevealRef.current) { + shouldFollowEndOnResizeRef.current = true; + } + handleShowPreviousMessages(); + }; + + useEffect(() => { + if (!showFullHistory) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + return; + } + if (!shouldRestoreTailAfterHistoryRevealRef.current) { + return; + } + const animationFrame = requestAnimationFrame(() => { + if (!shouldRestoreTailAfterHistoryRevealRef.current || !shouldFollowEndOnResizeRef.current) { + return; + } + legendListRef.current?.scrollToEnd({animated: false}); + const hasReceivedHistoryPage = oldestLoadedActionID !== oldestLoadedActionIDAtHistoryRevealRef.current || !hasOlderActions; + if (!oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError))) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + } + }); + return () => cancelAnimationFrame(animationFrame); + }, [showFullHistory, listData.length, oldestLoadedActionID, hasOlderActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); + + const reportScrollManager = useReportScrollManager(); + + // Which rows are on screen, tracked so an edit started on a message the user can already see doesn't move the list. + const viewableRowIndexesRef = useRef>(new Set()); + + const expandedFocusIndex = focusRequestedReportActionID ? (reportActionIDToDisplayIndex.get(focusRequestedReportActionID) ?? -1) : -1; + useEffect(() => { + if (expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex)) { + return; + } + + // An off-window revealed message must mount before it can hand off focus. + const frame = requestAnimationFrame(() => { + listRef.current?.scrollToIndex({index: expandedFocusIndex, animated: false, viewPosition: 0.5}); + }); + return () => cancelAnimationFrame(frame); + }, [expandedFocusIndex, focusRequestedReportActionID, listRef]); + + // The rendered indexes are known here, so scrolling the row into view mounts it and keeps the message visible while it's edited. + useScrollToEditingReportAction({ + visibleReportActions: listData, + scrollToIndex: (index) => { + // Already on screen, so its editor has mounted and taken focus — scrolling would only yank the user. + if (viewableRowIndexesRef.current.has(index)) { + return; + } + + reportScrollManager.scrollToIndex(index, {animated: false, viewPosition: 0.5}); + }, + }); + + // Find the index of the action badge target in the chronological data rendered by LegendList. + const actionBadgeTargetIndex = actionBadgeTargetID + ? (reportActionIDToDisplayIndex.get(actionBadgeTargetID) ?? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID)) + : -1; + const { + trackVerticalScrolling, + onViewableItemsChanged, + isFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + scrollToBottomAndMarkReportAsRead, + scrollToActionBadgeTarget, + shouldBeAlignedToTop, + initialScrollIndex, + initialScrollIndexParams, + onLoad, + onItemSizeChanged, + trackLinkedMessageScroll, + stopLinkedMessagePositioning, + } = useReportActionsScroll({ + listID, + reportID, + conciergeChat, + report, + transactionThreadReport, + parentReportAction, + sortedVisibleReportActions, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + keyExtractor: getReportActionKey, + markNewestActionAsRead, + completeSkippedMarkAsRead, + unreadMarkerReportActionID, + unreadMarkerReportActionIndex, + hasNewerActions, + actionBadgeTargetIndex, + sortedAllReportActionsForPagination: sortedAllReportActions ?? [], + treatAsNoPaginationAnchor, + setTreatAsNoPaginationAnchor, + }); + + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + // Cover every initial LegendList layout, including cached chats, until the rows are positioned. + const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + + const readyEndListIDRef = useRef(undefined); + const lastScrollMetricsRef = useRef(undefined); + const endCorrectionFrameRef = useRef(undefined); + const userScrolledListIDRef = useRef(undefined); + + const stopFollowingEnd = () => { + userScrolledListIDRef.current = listID; + shouldFollowEndOnResizeRef.current = false; + shouldRestoreTailAfterHistoryRevealRef.current = false; + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; + }; + + const handleScrollBeginDrag = () => { + stopLinkedMessagePositioning(); + stopFollowingEnd(); + }; + + const scheduleEndCorrection = () => { + if (readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined) { + return; + } + endCorrectionFrameRef.current = requestAnimationFrame(() => { + endCorrectionFrameRef.current = undefined; + if (readyEndListIDRef.current === listID && shouldFollowEndOnResizeRef.current) { + legendListRef.current?.scrollToEnd({animated: false}); + } + }); + }; + + useEffect( + () => () => { + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; + }, + [listID], + ); + + const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { + viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index)); + onViewableItemsChanged(info); + for (const item of info.changed) { + if (!item.isViewable) { + continue; + } + allowLegendListItemOverflow(legendListRef.current, item.index); + } + }; + + const handleListReady = () => { + // onLoad only means rows rendered; keep their estimated positions covered until initial scrolling settles. + onLoad(); + setLoadedInitialViewportListID(listID); + readyEndListIDRef.current = listID; + // An unread marker near the newest action lands at the end. Rows above it are still measured after + // onReady, so keep that end in view instead of exposing older messages. + const isUnreadInitialScroll = initialScrollIndex !== undefined && !reportActionIDFromRoute && !shouldBeAlignedToTop; + const didLandAtEnd = initialScrollIndex === undefined || (isUnreadInitialScroll && !!legendListRef.current?.getState()?.isAtEnd); + shouldFollowEndOnResizeRef.current = didLandAtEnd && !hasNewerActions && userScrolledListIDRef.current !== listID; + lastScrollMetricsRef.current = undefined; + // The final row can grow after LegendList's initial end estimate (for example, Concierge feedback). + scheduleEndCorrection(); + }; + + const loadOlderChatsOnStartReached = () => { + if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { + return; + } + + lastRequestedOldestActionIDRef.current = oldestReportActionID; + loadOlderChats(false); + }; + + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + trackLinkedMessageScroll(event); + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + distanceFromBottomRef.current = distanceFromBottom; + const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; + const previousMetrics = lastScrollMetricsRef.current; + if (readyEndListIDRef.current === listID && !hasNewerActions) { + if (distanceFromBottom < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD) { + shouldFollowEndOnResizeRef.current = true; + } else if ( + (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || + (previousMetrics && + previousMetrics.contentHeight === contentSize.height && + previousMetrics.viewportHeight === layoutMeasurement.height && + endCorrectionFrameRef.current === undefined) + ) { + // A pending history request must not reclaim the end after the reader has moved away. + stopFollowingEnd(); + } + } + lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; + + if (isNearStart) { + loadOlderChatsOnStartReached(); + } else { + lastRequestedOldestActionIDRef.current = undefined; + } + + const bottomRelativeEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: distanceFromBottom}, + }, + }; + + trackVerticalScrolling(bottomRelativeEvent); + onScrolledOverThresholdChange(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + emitComposerScrollEvents(); + }; + + const loadNewerChatsAfterTransitions = () => { + if (!isSearchTopmostFullScreenRoute()) { + loadNewerChats(false); + return; + } + + TransitionTracker.runAfterTransitions({ + callback: () => { + requestAnimationFrame(() => loadNewerChats(false)); + }, + }); + }; + + useFollowActionBadgeTarget({ + reportID, + actionTargetReportActionID: actionBadgeTargetID, + actionBadgeTargetIndex, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + scrollToActionBadgeTarget, + }); + + // Keep bottom-relative scroll metrics current when the viewport is resized. + const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + const state = legendListRef.current?.getState(); + if (state && readyEndListIDRef.current === listID) { + const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); + scrollOffsetRef.current = distanceFromBottom; + distanceFromBottomRef.current = distanceFromBottom; + } + scheduleEndCorrection(); + }; + + const trackContentSizeChange = () => { + trackVerticalScrolling(undefined); + scheduleEndCorrection(); + }; + + return { + listID, + loadedInitialViewportListID, + shouldShowInitialViewportSkeleton, + shouldBeAlignedToTop, + onShowPreviousMessages, + isFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + scrollToBottomAndMarkReportAsRead, + scrollToActionBadgeTarget, + legendListProps: { + ref: legendListRef, + keyExtractor: getReportActionKey, + getItemType: getReportActionItemType, + onEndReached: loadNewerChatsAfterTransitions, + onEndReachedThreshold: PAGINATION_THRESHOLD, + onLayout: recordTimeToMeasureItemLayout, + onScroll: trackScrollPositionAndThreshold, + onScrollBeginDrag: handleScrollBeginDrag, + onStartReached: loadOlderChatsOnStartReached, + onStartReachedThreshold: PAGINATION_THRESHOLD, + onViewableItemsChanged: updateVisibleItemOverflow, + initialScrollAtEnd: initialScrollIndex === undefined, + initialScrollIndex: initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}, + alignItemsAtEnd: !shouldBeAlignedToTop, + onReady: handleListReady, + onItemSizeChanged, + onContentSizeChange: trackContentSizeChange, + }, + }; +} + +export default useReportActionsListScroll; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts new file mode 100644 index 000000000000..f0bb9e29a6da --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts @@ -0,0 +1,25 @@ +/** Completes warm report spans once positioned content is visible, including when the report regains focus. */ +import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; + +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; + +import {useIsFocused} from '@react-navigation/native'; +import {useEffect} from 'react'; + +import type {UseReportActionsListTelemetryParams} from './types'; + +function useReportActionsListTelemetry({reportID, listID, loadedInitialViewportListID, shouldShowInitialViewportSkeleton}: UseReportActionsListTelemetryParams) { + const isFocused = useIsFocused(); + const {report} = useReportActionsListState(); + + useEffect(() => { + if (!isFocused || shouldShowInitialViewportSkeleton || loadedInitialViewportListID !== listID) { + return; + } + // onReady positions the list, but only this committed render has removed its cover. + // Refocus also completes new spans when the positioned list stays mounted. + markOpenReportEnd(reportID, report, {warm: true}); + }, [isFocused, listID, loadedInitialViewportListID, report, reportID, shouldShowInitialViewportSkeleton]); +} + +export default useReportActionsListTelemetry; From bf6075f4c5095fa659160ee0be4e04f58af466ac Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 29 Oct 2026 21:22:35 +0000 Subject: [PATCH 072/109] test: cover unread tracking across focus and marker changes Co-authored-by: Codex --- ...seReportUnreadMessageScrollTrackingTest.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index 88bb39550ca4..8f7f04168fe3 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -8,6 +8,8 @@ import CONST from '@src/CONST'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import {useIsFocused} from '@react-navigation/native'; + import createMock from '../utils/createMock'; jest.mock('@react-navigation/native', () => { @@ -25,6 +27,10 @@ const emptyScrollEventMock = createMock> }); describe('useReportUnreadMessageScrollTracking', () => { + beforeEach(() => { + jest.mocked(useIsFocused).mockReturnValue(true); + }); + describe('on init and without any scrolling', () => { const onTrackScrollingMockFn = jest.fn(); @@ -78,6 +84,122 @@ describe('useReportUnreadMessageScrollTracking', () => { describe('when scrolling', () => { const onTrackScrollingMockFn = jest.fn(); + it('resumes pill tracking after initial positioning and hides it when returning to the newest messages', () => { + // Given initial positioning keeps the pill hidden while the list reports its scroll position. + const offsetRef = {current: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD + 100}; + const onTrackScrolling = jest.fn(); + const {result, rerender} = renderHook( + ({shouldDisablePillTracking}) => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: jest.fn(), + onTrackScrolling, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: false, + shouldDisablePillTracking, + }), + {initialProps: {shouldDisablePillTracking: true}}, + ); + + // When a scroll event arrives before positioning finishes. + act(() => result.current.trackVerticalScrolling(emptyScrollEventMock)); + + // Then the event is forwarded without exposing the latest-messages pill during positioning. + expect(onTrackScrolling).toHaveBeenCalledWith(emptyScrollEventMock); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + + // When positioning finishes and the user scrolls through older messages. + rerender({shouldDisablePillTracking: false}); + act(() => result.current.trackVerticalScrolling(emptyScrollEventMock)); + + // Then the pill offers a way back to the newest messages. + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + // When the user reaches the newest messages. + act(() => { + offsetRef.current = 0; + result.current.trackVerticalScrolling(emptyScrollEventMock); + }); + + // Then the pill disappears because there are no newer messages to load. + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + }); + + it.each([false, true])('ignores viewability events behind another screen and resumes after refocus when inverted is %s', (isInverted) => { + // Given the unread marker is outside the viewport and the pill is visible. + const onUnreadActionVisible = jest.fn(); + const {result, rerender} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: {current: 0}, + onUnreadActionVisible, + onTrackScrolling: jest.fn(), + hasNewerActions: false, + unreadMarkerReportActionIndex: 5, + isInverted, + }), + ); + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: isInverted ? 6 : 4, key: 'older-message', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + // When another screen covers the chat and a background layout reports the unread marker as visible. + jest.mocked(useIsFocused).mockReturnValue(false); + rerender({}); + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'unread-message', isViewable: true, item: {}}], changed: []}); + }); + + // Then the hidden chat neither marks the message as read nor hides its pill. + expect(onUnreadActionVisible).not.toHaveBeenCalled(); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + // When the user returns and the unread message becomes visible in the chat. + jest.mocked(useIsFocused).mockReturnValue(true); + rerender({}); + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'unread-message', isViewable: true, item: {}}], changed: []}); + }); + + // Then read tracking resumes and the pill disappears. + expect(onUnreadActionVisible).toHaveBeenCalledTimes(1); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + }); + + it.each([false, true])('updates the pill when the unread marker moves into the unchanged viewport and inverted is %s', (isInverted) => { + // Given the visible rows have not reached the unread marker. + const onUnreadActionVisible = jest.fn(); + const visibleIndex = isInverted ? 6 : 4; + const {result, rerender} = renderHook( + ({unreadMarkerReportActionIndex}) => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: {current: 0}, + onUnreadActionVisible, + onTrackScrolling: jest.fn(), + hasNewerActions: false, + unreadMarkerReportActionIndex, + isInverted, + }), + {initialProps: {unreadMarkerReportActionIndex: 5}}, + ); + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: visibleIndex, key: 'visible-message', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + expect(onUnreadActionVisible).not.toHaveBeenCalled(); + + // When the unread marker changes without another viewability event from the list. + rerender({unreadMarkerReportActionIndex: visibleIndex}); + + // Then the stored viewport completes read tracking and hides the pill. + expect(onUnreadActionVisible).toHaveBeenCalledTimes(1); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + }); + it('returns floatingMessage visibility as true when scrolling outside of threshold', () => { // Given const offsetRef = {current: 0}; From 6f5391a863d35db06fe8baaf08d11a78cc34bbec Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 30 Oct 2026 08:33:34 +0000 Subject: [PATCH 073/109] fix: keep offline reports covered until list readiness Keep cached messages covered until LegendList finishes positioning them. Offline reports still skip waiting for the queued OpenReport request. Co-authored-by: Codex --- .../useReportActionsListScroll.ts | 3 ++- tests/ui/ReportActionsListTest.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index e9a2b4deaf44..9c85ea84a1f9 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -223,7 +223,8 @@ function useReportActionsListScroll({ const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); // Cover every initial LegendList layout, including cached chats, until the rows are positioned. - const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + // Offline reports still need their cached rows positioned, but cannot wait for OpenReport. + const shouldShowInitialViewportSkeleton = loadedInitialViewportListID !== listID || (!isOffline && isInitialReportLoadPending); const readyEndListIDRef = useRef(undefined); const lastScrollMetricsRef = useRef(undefined); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 0343bed34146..c916542f3861 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -965,6 +965,25 @@ describe('ReportActionsList (body)', () => { expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); }); + it('covers cached offline messages until the list is positioned without waiting for OpenReport', () => { + // Given cached messages and an initial request that cannot finish while offline. + mockUseNetwork.mockReturnValue({isOffline: true}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + mockShouldCallLegendListOnLoad = false; + + // When the report opens before LegendList finishes positioning its cached messages. + renderReportActionsList(); + + // Then the skeleton hides the unfinished viewport until the list is ready, regardless of the queued request. + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + act(() => getCapturedListProps()?.onLoad?.()); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + act(() => getCapturedListProps()?.onReady?.()); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + it('keeps the positioned list mounted when sending a message clears the linked action', async () => { // Given a linked message has finished its initial positioning. mockUseNetwork.mockReturnValue({isOffline: false}); From 0750137e54e4e675015daf9dc72deedeeeedf48b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 30 Oct 2026 09:56:38 +0000 Subject: [PATCH 074/109] test: document chronological unread tracking cases Co-Authored-By: Claude Opus 5.5 --- tests/unit/useReportUnreadMessageScrollTrackingTest.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index 8f7f04168fe3..914a29973d3d 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -351,6 +351,7 @@ describe('useReportUnreadMessageScrollTracking', () => { }); it('tracks an unread marker in a chronological list', () => { + // Given a chronological list whose unread marker sits below the visible rows. const offsetRef = {current: 0}; const {result} = renderHook(() => useReportUnreadMessageScrollTracking({ @@ -364,14 +365,20 @@ describe('useReportUnreadMessageScrollTracking', () => { }), ); + // When only older rows are visible. act(() => { result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); }); + + // Then the pill offers a way to the unread message, which has a higher index in this order. expect(result.current.isFloatingMessageCounterVisible).toBe(true); + // When the unread message becomes visible. act(() => { result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'reportActions_5', isViewable: true, item: {}}], changed: []}); }); + + // Then the pill hides and the message counts as read. expect(result.current.isFloatingMessageCounterVisible).toBe(false); expect(onUnreadActionVisibleMockFn).toHaveBeenCalled(); }); @@ -523,6 +530,7 @@ describe('useReportUnreadMessageScrollTracking', () => { }); it('returns isActionBadgeAboveViewport as true for a lower index above a chronological viewport', () => { + // Given a chronological list, where older rows have lower indexes, and a badge target at index 1. const offsetRef = {current: 0}; const {result} = renderHook(() => useReportUnreadMessageScrollTracking({ @@ -537,10 +545,12 @@ describe('useReportUnreadMessageScrollTracking', () => { }), ); + // When the viewport starts at index 3. act(() => { result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); }); + // Then the badge target is above the viewport. expect(result.current.isActionBadgeAboveViewport).toBe(true); }); From 70f1967e14ecd55e9b86bdec118ebb45a32559a0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 30 Oct 2026 12:38:42 +0000 Subject: [PATCH 075/109] refactor: move report action row rendering into a hook Co-Authored-By: Claude Opus 5.5 --- .../ReportActionsListContent.tsx | 207 ++--------------- .../inbox/report/ReportActionsList/types.ts | 33 ++- .../useReportActionsListRenderItem.tsx | 214 ++++++++++++++++++ 3 files changed, 264 insertions(+), 190 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index 853c470b3a67..f7f7700b89fe 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -10,76 +10,45 @@ import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; -import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; import useSidePanelState from '@hooks/useSidePanelState'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; -import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import { - getFirstVisibleReportActionID, - getFeedbackPromptActionIDs, - isConsecutiveActionMadeByPreviousActor, - isDeletedParentAction, - isReversedTransaction, - isTransactionThread, - shouldHidePayAction, -} from '@libs/ReportActionsUtils'; -import { - chatIncludesChronosWithID, - isArchivedNonExpenseReport, - isArchivedReport, - isCanceledTaskReport, - isExpenseReport, - isHarvestCreatedExpenseReport, - isInvoiceReport, - isIOUReport, - isTaskReport, - isSupportTicket, - shouldShowMarkAsDone, -} from '@libs/ReportUtils'; +import {shouldHidePayAction} from '@libs/ReportActionsUtils'; +import {isArchivedReport, shouldShowMarkAsDone} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useActionListContext} from '@pages/inbox/ActionListContext'; import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import AskConciergeWelcome from '@pages/inbox/report/AskConciergeWelcome'; -import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import {ReportActionPositionContextProvider} from '@pages/inbox/report/ReportActionIndexContext'; import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; import ReportActionsListHeader from '@pages/inbox/report/ReportActionsListHeader'; -import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import ReportActionsListPaddingView from '@pages/inbox/report/ReportActionsListPaddingView'; -import ShowPreviousMessagesButton from '@pages/inbox/report/ShowPreviousMessagesButton'; 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 getEmptyArray from '@src/types/utils/getEmptyArray'; -import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; -import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {accountIDSelector} from '@selectors/Session'; import React, {useState} from 'react'; import {Platform, View} from 'react-native'; import type {ReportActionsListProps} from './types'; import useReportActionsListData from './useReportActionsListData'; +import useReportActionsListRenderItem from './useReportActionsListRenderItem'; import useReportActionsListScroll from './useReportActionsListScroll'; import useReportActionsListTelemetry from './useReportActionsListTelemetry'; @@ -96,14 +65,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct hasNewerActions, oldestUnreadReportAction, transactionThreadReport, - parentReportActionForTransactionThread, - parentReportAction, sortedReportActions: unfilteredReportActions, sortedVisibleReportActions: unfilteredVisibleReportActions, isConciergeHiddenHistory, - showFullHistory, - hasPreviousMessages, - persistedReportActionIDs, shouldShowConciergeWelcome, isConciergeHistoryExpanded, } = useReportActionsListState(); @@ -116,11 +80,11 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {isOffline} = useNetwork(); const route = useRoute>(); - const reportActionIDFromRoute = route?.params?.reportActionID; + const linkedReportActionID = route?.params?.reportActionID; const {sessionStartTime} = useSidePanelState(); const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); - useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); + useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute: linkedReportActionID}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. useRetireMerchantRuleSuggestionOnLeave(reportID); @@ -145,18 +109,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { selector: reportAttributesSelector, }); - const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - - const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); - const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); - const reportsParentHierarchy = useReportsParentHierarchy(reportID); - - const linkedReportActionID = reportActionIDFromRoute; const {getScrollOffset} = useActionListContext(); - - const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; - const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, @@ -177,22 +131,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct hasNewerActions, }); - const { - renderedVisibleReportActions, - reportActionsToRender, - listData, - canonicalIndexByReportActionID, - runsByAnchorReportActionID, - reportActionIDToDisplayIndex, - expandedSystemMessageReportActionIDs, - unreadMarkerListIndex, - expandSystemMessageRun, - focusRequestedReportActionID, - onExpandedSystemMessageReady, - draftMessageHTML, - draftReportActionID, - isDraftPendingCompletion, - } = useReportActionsListData({ + const reportActionsListData = useReportActionsListData({ sortedVisibleReportActions, reportNameValuePairs, isOffline, @@ -200,6 +139,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct linkedReportActionID, unreadMarkerReportActionID, }); + const {listData, reportActionIDToDisplayIndex, unreadMarkerListIndex, focusRequestedReportActionID, isDraftPendingCompletion} = reportActionsListData; const { listID, @@ -232,128 +172,17 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct useReportActionsListTelemetry({reportID, listID, loadedInitialViewportListID, shouldShowInitialViewportSkeleton}); - const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); - - const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - - // An agent account can be opened through Copilot, and the agent must not rate its own replies - const actorAccountIDs = [...new Set(renderedVisibleReportActions.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); - const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); - - // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet - const latestConciergeFeedbackActionIDs = - reportNameValuePairs?.conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions - ? getEmptyArray() - : getFeedbackPromptActionIDs(renderedVisibleReportActions, persistedReportActionIDs, customAgentAccountIDs); - - /** - * Thread's divider line should hide when the first chat in the thread is marked as unread. - * This is so that it will not be conflicting with header's separator line. - */ - const shouldHideThreadDividerLine = firstVisibleReportActionID === unreadMarkerReportActionID; - - const shouldUseThreadDividerLine = (() => { - const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; - - if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { - return false; - } - - if (isTransactionThread(parentReportAction)) { - return !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction); - } - - if (isTaskReport(report)) { - return !isCanceledTaskReport(report, parentReportAction); - } - - if (isSupportTicket(report)) { - return true; - } - - return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); - })(); - - const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { - const canonicalIndex = canonicalIndexByReportActionID.get(reportAction.reportActionID) ?? index; - const systemMessageRun = runsByAnchorReportActionID.get(reportAction.reportActionID); - const previousReportAction = index > 0 ? listData.at(index - 1) : undefined; - const previousRun = previousReportAction ? runsByAnchorReportActionID.get(previousReportAction.reportActionID) : undefined; - const isAfterCollapsedRun = !!previousRun && !previousRun.isExpanded; - const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; - - return ( - - {systemMessageRun && !systemMessageRun.isExpanded ? ( - expandSystemMessageRun(systemMessageRun.reportActionIDs)} - unreadMarkerReportActionID={ - unreadMarkerReportActionID && - systemMessageRun.reportActionIDs.includes(unreadMarkerReportActionID) && - (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) - ? unreadMarkerReportActionID - : undefined - } - /> - ) : ( - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} - reportsParentHierarchy={reportsParentHierarchy} - onExpandedSystemMessageReady={ - reportAction.reportActionID === focusRequestedReportActionID ? (target) => onExpandedSystemMessageReady(reportAction.reportActionID, target) : undefined - } - shouldUseThreadDividerLine={shouldUseThreadDividerLine} - isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - /> - )} - {!!reportStable?.reportID && ( - - )} - - ); - }; - - // Ensure row presentation updates when these values change without changing the action data. - const extraData = [ + const {renderItem, extraData} = useReportActionsListRenderItem({ + ...reportActionsListData, + reportID, + reportNameValuePairs, + isReportArchived, + isOffline, + sortedReportActions, unreadMarkerReportActionID, - isArchivedNonExpenseReport(report, isReportArchived), - draftReportActionID, - draftMessageHTML, - isDraftPendingCompletion, - latestConciergeFeedbackActionIDs, - expandedSystemMessageReportActionIDs, - focusRequestedReportActionID, - ]; + linkedReportActionID, + onShowPreviousMessages, + }); const latestActionsFooter = ( <> @@ -362,7 +191,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isHistoryShown onPress={() => { collapseConciergeHistory(); - if (reportActionIDFromRoute) { + if (linkedReportActionID) { Navigation.setParams({reportActionID: ''}, route.key); } }} diff --git a/src/pages/inbox/report/ReportActionsList/types.ts b/src/pages/inbox/report/ReportActionsList/types.ts index 7198e1af6aab..95697b405d74 100644 --- a/src/pages/inbox/report/ReportActionsList/types.ts +++ b/src/pages/inbox/report/ReportActionsList/types.ts @@ -7,6 +7,8 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import type useReportActionsListData from './useReportActionsListData'; + type ReportActionsListProps = { /** The ID of the report to display actions for */ reportID: string; @@ -42,6 +44,28 @@ type UseReportActionsListScrollParams = Pick< onScrolledOverThresholdChange: (isOverThreshold: boolean) => void; }; +type UseReportActionsListRenderItemParams = Pick< + ReturnType, + | 'renderedVisibleReportActions' + | 'reportActionsToRender' + | 'listData' + | 'canonicalIndexByReportActionID' + | 'runsByAnchorReportActionID' + | 'expandedSystemMessageReportActionIDs' + | 'expandSystemMessageRun' + | 'focusRequestedReportActionID' + | 'onExpandedSystemMessageReady' + | 'draftMessageHTML' + | 'draftReportActionID' + | 'isDraftPendingCompletion' +> & + Pick & + Pick & { + isReportArchived: boolean; + sortedReportActions: OnyxTypes.ReportAction[]; + onShowPreviousMessages: () => void; + }; + type UseReportActionsListTelemetryParams = { reportID: string; listID: string; @@ -55,4 +79,11 @@ type ReportActionsListScrollMetrics = { viewportHeight: number; }; -export type {ReportActionsListProps, UseReportActionsListDataParams, UseReportActionsListScrollParams, UseReportActionsListTelemetryParams, ReportActionsListScrollMetrics}; +export type { + ReportActionsListProps, + UseReportActionsListDataParams, + UseReportActionsListScrollParams, + UseReportActionsListRenderItemParams, + UseReportActionsListTelemetryParams, + ReportActionsListScrollMetrics, +}; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx new file mode 100644 index 000000000000..81679519f966 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx @@ -0,0 +1,214 @@ +import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; + +import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import { + getFirstVisibleReportActionID, + getFeedbackPromptActionIDs, + isConsecutiveActionMadeByPreviousActor, + isDeletedParentAction, + isReversedTransaction, + isTransactionThread, +} from '@libs/ReportActionsUtils'; +import { + chatIncludesChronosWithID, + isArchivedNonExpenseReport, + isCanceledTaskReport, + isExpenseReport, + isHarvestCreatedExpenseReport, + isInvoiceReport, + isIOUReport, + isTaskReport, + isSupportTicket, +} from '@libs/ReportUtils'; + +import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; +import {ReportActionPositionContextProvider} from '@pages/inbox/report/ReportActionIndexContext'; +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; +import ShowPreviousMessagesButton from '@pages/inbox/report/ShowPreviousMessagesButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {getStableReportSelector} from '@src/selectors/Report'; +import type * as OnyxTypes from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; + +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; + +import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; +import {accountIDSelector} from '@selectors/Session'; +import React from 'react'; + +import type {UseReportActionsListRenderItemParams} from './types'; + +/** Builds the report-actions row renderer and the `extraData` values that must re-render rows when they change. */ +function useReportActionsListRenderItem({ + reportID, + reportNameValuePairs, + isReportArchived, + isOffline, + sortedReportActions, + renderedVisibleReportActions, + reportActionsToRender, + listData, + canonicalIndexByReportActionID, + runsByAnchorReportActionID, + expandedSystemMessageReportActionIDs, + expandSystemMessageRun, + focusRequestedReportActionID, + onExpandedSystemMessageReady, + draftMessageHTML, + draftReportActionID, + isDraftPendingCompletion, + unreadMarkerReportActionID, + linkedReportActionID, + onShowPreviousMessages, +}: UseReportActionsListRenderItemParams) { + const { + report, + hasNewerActions, + transactionThreadReport, + parentReportActionForTransactionThread, + parentReportAction, + isConciergeHiddenHistory, + showFullHistory, + hasPreviousMessages, + persistedReportActionIDs, + } = useReportActionsListState(); + const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + + const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); + const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); + const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); + + const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); + + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + + // An agent account can be opened through Copilot, and the agent must not rate its own replies + const actorAccountIDs = [...new Set(renderedVisibleReportActions.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); + const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); + + // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet + const latestConciergeFeedbackActionIDs = + reportNameValuePairs?.conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions + ? getEmptyArray() + : getFeedbackPromptActionIDs(renderedVisibleReportActions, persistedReportActionIDs, customAgentAccountIDs); + + /** + * Thread's divider line should hide when the first chat in the thread is marked as unread. + * This is so that it will not be conflicting with header's separator line. + */ + const shouldHideThreadDividerLine = firstVisibleReportActionID === unreadMarkerReportActionID; + + const shouldUseThreadDividerLine = (() => { + const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; + + if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + return false; + } + + if (isTransactionThread(parentReportAction)) { + return !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction); + } + + if (isTaskReport(report)) { + return !isCanceledTaskReport(report, parentReportAction); + } + + if (isSupportTicket(report)) { + return true; + } + + return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); + })(); + + const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { + const canonicalIndex = canonicalIndexByReportActionID.get(reportAction.reportActionID) ?? index; + const systemMessageRun = runsByAnchorReportActionID.get(reportAction.reportActionID); + const previousReportAction = index > 0 ? listData.at(index - 1) : undefined; + const previousRun = previousReportAction ? runsByAnchorReportActionID.get(previousReportAction.reportActionID) : undefined; + const isAfterCollapsedRun = !!previousRun && !previousRun.isExpanded; + const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + + return ( + + {systemMessageRun && !systemMessageRun.isExpanded ? ( + expandSystemMessageRun(systemMessageRun.reportActionIDs)} + unreadMarkerReportActionID={ + unreadMarkerReportActionID && + systemMessageRun.reportActionIDs.includes(unreadMarkerReportActionID) && + (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) + ? unreadMarkerReportActionID + : undefined + } + /> + ) : ( + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} + reportsParentHierarchy={reportsParentHierarchy} + onExpandedSystemMessageReady={ + reportAction.reportActionID === focusRequestedReportActionID ? (target) => onExpandedSystemMessageReady(reportAction.reportActionID, target) : undefined + } + shouldUseThreadDividerLine={shouldUseThreadDividerLine} + isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + )} + {!!reportStable?.reportID && ( + + )} + + ); + }; + + // Ensure row presentation updates when these values change without changing the action data. + const extraData = [ + unreadMarkerReportActionID, + isArchivedNonExpenseReport(report, isReportArchived), + draftReportActionID, + draftMessageHTML, + isDraftPendingCompletion, + latestConciergeFeedbackActionIDs, + expandedSystemMessageReportActionIDs, + focusRequestedReportActionID, + ]; + + return {renderItem, extraData}; +} + +export default useReportActionsListRenderItem; From 7b82800361f06d404442fd796492ffb428487044 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 30 Oct 2026 12:55:27 +0000 Subject: [PATCH 076/109] refactor: keep the report actions list wrapper in its index file Co-Authored-By: Claude Opus 5.5 --- src/pages/inbox/report/ReportActionsList/index.ts | 3 --- .../ReportActionsList/{ReportActionsList.tsx => index.tsx} | 0 2 files changed, 3 deletions(-) delete mode 100644 src/pages/inbox/report/ReportActionsList/index.ts rename src/pages/inbox/report/ReportActionsList/{ReportActionsList.tsx => index.tsx} (100%) diff --git a/src/pages/inbox/report/ReportActionsList/index.ts b/src/pages/inbox/report/ReportActionsList/index.ts deleted file mode 100644 index 39d5e81bb840..000000000000 --- a/src/pages/inbox/report/ReportActionsList/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ReportActionsList from './ReportActionsList'; - -export default ReportActionsList; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList/index.tsx similarity index 100% rename from src/pages/inbox/report/ReportActionsList/ReportActionsList.tsx rename to src/pages/inbox/report/ReportActionsList/index.tsx From 0d1f69d94b6f3228fe7ca6cdb4f5af396e6571f6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 2 Nov 2026 15:28:46 +0000 Subject: [PATCH 077/109] chore(deps): use LegendList 3.7.1 upstream fixes Co-authored-by: Codex --- package-lock.json | 8 +-- package.json | 2 +- ....0+001+account-for-android-end-clamp.patch | 68 ------------------- patches/@legendapp/list/details.md | 16 ----- 4 files changed, 5 insertions(+), 89 deletions(-) delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch delete mode 100644 patches/@legendapp/list/details.md diff --git a/package-lock.json b/package-lock.json index 71067d9d1ea8..fdca358eefca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.0", + "@legendapp/list": "^3.7.1", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11726,9 +11726,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.0.tgz", - "integrity": "sha512-Er2D5eKIv+rfbakJ3ra4pD7NCNtOj2m1nrU6s6Ea3dO+x3vgt0h9NE0dn5WHOLIRkPEWHiwbslgl95QxZiEkjg==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.1.tgz", + "integrity": "sha512-zgIy84yG2JP93+kib61rZBTcYGZ6fULrkCQz/MF7WrjVJeJQa/rR4z+rXfM5oveirO5mYkro4IDGytARIjJe9w==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index b9beeb91fbce..4bfe3a6d570f 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.0", + "@legendapp/list": "^3.7.1", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/patches/@legendapp/list/@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch b/patches/@legendapp/list/@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch deleted file mode 100644 index d484e54731e0..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch +++ /dev/null @@ -1,68 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index 59ecd66..432f476 100644 ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -1642,6 +1642,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { - } - return 0; - } -+// Android clamps the offset to the new end while mounting a content shrink, then native MVCP adds the -+// anchor delta on top. Sync JS state to that clamp and only request the rest of the movement. -+function getAndroidNativeClampedAdjust(ctx, positionDiff) { -+ const state = ctx.state; -+ const maxScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); -+ if (state.scroll <= maxScroll + MVCP_POSITION_EPSILON) { -+ return positionDiff; -+ } -+ const targetScroll = Math.min(state.scroll + positionDiff, maxScroll); -+ state.scroll = maxScroll; -+ state.scrollPending = maxScroll; -+ return targetScroll - maxScroll; -+} - function getProgressTowardAmount(targetDelta, nativeDelta) { - return targetDelta < 0 ? -nativeDelta : nativeDelta; - } -@@ -1884,6 +1897,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - now, - positionDiff - }); -+ if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { -+ positionDiff = getAndroidNativeClampedAdjust(ctx, positionDiff); -+ } - if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { - state.pendingNativeMVCPAdjust = { - amount: positionDiff, -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index 5bf1496..8262cbd 100644 ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -1621,6 +1621,19 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { - } - return 0; - } -+// Android clamps the offset to the new end while mounting a content shrink, then native MVCP adds the -+// anchor delta on top. Sync JS state to that clamp and only request the rest of the movement. -+function getAndroidNativeClampedAdjust(ctx, positionDiff) { -+ const state = ctx.state; -+ const maxScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); -+ if (state.scroll <= maxScroll + MVCP_POSITION_EPSILON) { -+ return positionDiff; -+ } -+ const targetScroll = Math.min(state.scroll + positionDiff, maxScroll); -+ state.scroll = maxScroll; -+ state.scrollPending = maxScroll; -+ return targetScroll - maxScroll; -+} - function getProgressTowardAmount(targetDelta, nativeDelta) { - return targetDelta < 0 ? -nativeDelta : nativeDelta; - } -@@ -1863,6 +1876,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - now, - positionDiff - }); -+ if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { -+ positionDiff = getAndroidNativeClampedAdjust(ctx, positionDiff); -+ } - if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { - state.pendingNativeMVCPAdjust = { - amount: positionDiff, diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md deleted file mode 100644 index 9f48869a73df..000000000000 --- a/patches/@legendapp/list/details.md +++ /dev/null @@ -1,16 +0,0 @@ -# `@legendapp/list` patches - -The Fabric content-size fix from https://github.com/LegendApp/legend-list/commit/a2d20a9b is included in [LegendList 3.7.0](https://github.com/LegendApp/legend-list/blob/v3.7.0/CHANGELOG.md#370), so its local patch has been removed. - -### [@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch](@legendapp+list+3.7.0+001+account-for-android-end-clamp.patch) - -- Reason: On Android, a chat that opens at its newest messages can briefly show older messages before it snaps back to the end. When rows above the viewport get their real, smaller heights, the content shrinks. Android clamps the scroll offset to the new end, and LegendList's scroll-preservation anchor then moves the offset by the full shrink again. The list stays above the end by that amount until the next scroll to the end. Account for Android's clamp and only move the anchor by what the clamp doesn't cover. -- Scope: Only item-size scroll preservation on Android, and only when the content shrinks below the current offset. Data changes and active scroll targets already handle the clamp and are unchanged. iOS and web are unchanged. -- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/582 upstreams this patch. https://github.com/LegendApp/legend-list/pull/481 covers the related bottom-inset case. Keep this patch until the installed LegendList release includes this fix. -- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6287555197 -- PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: In a DM, mark the third-newest message as unread and go back to the inbox. Open the DM again. It should open at the newest messages without a frame showing older messages. Also scroll through older history and send a new message to check scroll preservation and end following. - -### Related upstream work - -- https://github.com/LegendApp/legend-list/pull/583 refreshes the cached end-follow state when `maintainScrollAtEndThreshold` changes. This supports a fixed pixel follow distance across viewport changes when using LegendList's built-in end following. From 680860498a7ffc3eb98b4e06da157ec8beb7265a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 2 Nov 2026 16:04:09 +0000 Subject: [PATCH 078/109] fix: restore unread positioning after clearing message links Treat a cleared message link as absent when choosing the initial unread target. Co-authored-by: Codex --- src/hooks/useReportActionsScroll.ts | 3 +- tests/unit/useReportActionsScrollTest.tsx | 50 +++++++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 93a6a48e16af..a56092485914 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -193,8 +193,9 @@ function useReportActionsScroll({ // When the report is aligned to the top, only the linked action should drive the initial scroll position and the unread marker must be ignored. // Otherwise, prefer the linked action and fall back to the unread marker. + // Sending from a link clears the route param to an empty string, which must not suppress unread positioning. let initialScrollKey = linkedReportActionID; - if (!shouldBeAlignedToTop && linkedReportActionID === undefined && unreadMarkerReportActionID) { + if (!shouldBeAlignedToTop && !linkedReportActionID && unreadMarkerReportActionID) { initialScrollKey = unreadMarkerReportActionID; } diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 70eef4d86b53..59f7de5d95e9 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -325,13 +325,18 @@ describe('useReportActionsScroll', () => { expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 1}); }); - it('top-aligns a linked report action when later actions follow it', async () => { - // Given a linked action followed by another action in chronological order. + it('prefers a linked action over a later unread marker', async () => { + // Given a linked action followed by an unread action in chronological order. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - const actions = [makeAction(LINKED_ACTION_ID), makeAction('999')]; + const actions = [makeAction(LINKED_ACTION_ID), makeAction(UNREAD_ACTION_ID)]; // When the list chooses its initial position. - const {result} = await renderScroll({sortedVisibleReportActions: actions.toReversed(), renderedVisibleReportActions: actions}); + const {result} = await renderScroll({ + sortedVisibleReportActions: actions.toReversed(), + renderedVisibleReportActions: actions, + unreadMarkerReportActionID: UNREAD_ACTION_ID, + unreadMarkerReportActionIndex: 1, + }); // Then the linked action is aligned below the header. expect(result.current.initialScrollIndex).toBe(0); @@ -353,6 +358,43 @@ describe('useReportActionsScroll', () => { expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); + it.each([undefined, ''])('opens at the unread boundary when the route link is %p', async (reportActionID) => { + // Given unread content before the newest message and no message targeted by the route. + mockRouteParams = {reportActionID}; + const actions = [makeAction('1'), makeAction(UNREAD_ACTION_ID), makeAction('2')]; + + // When reopening the chat, including after sending a message cleared its previous link. + const {result} = await renderScroll({ + sortedVisibleReportActions: actions.toReversed(), + renderedVisibleReportActions: actions, + unreadMarkerReportActionID: UNREAD_ACTION_ID, + unreadMarkerReportActionIndex: 1, + }); + + // Then initial placement preserves the unread boundary instead of starting at the newest message. + expect(result.current.initialScrollIndex).toBe(1); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('ignores unread positioning in a money request report after its link is cleared', async () => { + // Given a money request whose unread action follows its topmost action. + mockIsMoneyRequestReport = true; + mockRouteParams = {reportActionID: ''}; + const actions = [makeAction('1'), makeAction(UNREAD_ACTION_ID)]; + + // When opening the report without a message link, its report layout still owns the initial position. + const {result} = await renderScroll({ + sortedVisibleReportActions: actions.toReversed(), + renderedVisibleReportActions: actions, + unreadMarkerReportActionID: UNREAD_ACTION_ID, + unreadMarkerReportActionIndex: 1, + }); + + // Then unread content does not replace the report's required top alignment. + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toBeUndefined(); + }); + it('uses the rendered summary as the initial scroll target when the unread action is collapsed', async () => { // Given the unread action is hidden behind a displayed summary row. const {result} = await renderScroll({ From fe64a2d183ee592836f640254134f181c6595373 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 2 Nov 2026 16:38:39 +0000 Subject: [PATCH 079/109] fix: wait for newest messages before scrolling Co-authored-by: Codex --- src/hooks/useReportActionsScroll.ts | 24 ++------- .../useReportActionsNewActionLiveTail.ts | 41 +++++++++----- .../useReportActionsNewActionLiveTailTest.ts | 54 ++++++++++++++++++- tests/unit/useReportActionsScrollTest.tsx | 19 +++++-- 4 files changed, 98 insertions(+), 40 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index a56092485914..a7b9a5952935 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -14,11 +14,8 @@ import {useActionListContext} from '@pages/inbox/ActionListContext'; import useReportActionsNewActionLiveTail from '@pages/inbox/report/useReportActionsNewActionLiveTail'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; -import {openReport} from '@userActions/Report'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {ViewableItemsChanged} from '@src/types/utils/ReactNativeCompat'; @@ -31,7 +28,6 @@ import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {fullPolicySnapshotLastModifiedSelector} from '@selectors/Policy'; import {useEffect, useEffectEvent, useState} from 'react'; -import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useLinkedReportActionScroll from './useLinkedReportActionScroll'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; @@ -159,11 +155,9 @@ function useReportActionsScroll({ const {scrollOffsetRef, getListRef} = useActionListContext(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; - const backTo = route?.params?.backTo; const {isOffline} = useNetworkWithOfflineStatus(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${getNonEmptyStringOnyxID(reportID)}`); const [fullPolicySnapshotLastModified] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_FULL_POLICY_SNAPSHOT}${getNonEmptyStringOnyxID(report?.policyID)}`, { @@ -253,7 +247,7 @@ function useReportActionsScroll({ syncPillAfterComposerCollapse(); }, [isComposerFullSize]); - const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom} = useReportActionsNewActionLiveTail({ + const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom, requestLiveTailJump} = useReportActionsNewActionLiveTail({ conciergeChat, reportID, introSelected, @@ -404,20 +398,8 @@ function useReportActionsScroll({ } if (!hasNewestReportAction) { - if (!Navigation.getReportRHPActiveRoute()) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, backTo)); - } - openReport({ - reportID, - introSelected, - conciergeChat, - hasReportActions: true, - currentUserAccountID, - isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, - hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - fullPolicySnapshot, - }); - reportScrollManager.scrollToBottom(); + // The historical page's end is not the report's end. Reuse the send flow to wait for the newest page before scrolling. + requestLiveTailJump(); return; } reportScrollManager.scrollToBottom(); diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index 7419333d2a49..25b70480d26c 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -111,6 +111,32 @@ function useReportActionsNewActionLiveTail({ const isPendingScrollActionRendered = !!pendingScrollActionID && renderedVisibleReportActions.some((item) => item.reportActionID === pendingScrollActionID); + const requestLiveTailJump = () => { + if (liveTailJumpRef.current.stage !== 'idle') { + return; + } + if (isOffline) { + // Cached newest messages can be outside the linked page. Release that anchor without waiting for a network request. + liveTailJumpRef.current = {stage: 'await_scroll'}; + setTreatAsNoPaginationAnchor(true); + if (!isInSidePanel) { + navigation.setParams({reportActionID: ''}); + } + return; + } + liveTailJumpRef.current = {stage: 'open_report'}; + openReport({ + reportID, + introSelected, + conciergeChat, + hasReportActions: true, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + fullPolicySnapshot, + }); + }; + useEffect(() => { if (!pendingScrollActionID) { return; @@ -144,19 +170,7 @@ function useReportActionsNewActionLiveTail({ (hasNewerActions || !!linkedReportActionID || (!!unreadMarkerReportActionID && !hasNewestReportAction)); if (shouldJumpToLiveTail) { - if (liveTailJumpRef.current.stage === 'idle') { - liveTailJumpRef.current = {stage: 'open_report'}; - openReport({ - reportID, - introSelected, - conciergeChat, - hasReportActions: true, - currentUserAccountID, - isSelfTourViewed, - hasCompletedGuidedSetupFlow, - fullPolicySnapshot, - }); - } + requestLiveTailJump(); return; } @@ -278,6 +292,7 @@ function useReportActionsNewActionLiveTail({ isScrollToBottomEnabled: isScrollToBottomEnabled || isPendingScrollActionRendered, setIsScrollToBottomEnabled: setShouldScrollToBottom, completeLiveTailPruneAfterScrollToBottom, + requestLiveTailJump, }; } diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index e9fd2cbe88ee..f03c8f05041b 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -1,4 +1,4 @@ -import {act, renderHook} from '@testing-library/react-native'; +import {act, renderHook, waitFor} from '@testing-library/react-native'; import {FALLBACK_NAVIGATION_CONTEXT_VALUE} from '@components/withNavigationFallback'; @@ -121,6 +121,58 @@ describe('useReportActionsNewActionLiveTail', () => { mockIsInSidePanel = false; }); + it.each([false, true])('reaches the cached newest window offline when in side panel is %s', async (isInSidePanel) => { + // Given an offline report showing an older linked page. + mockIsInSidePanel = isInSidePanel; + const params = buildParams({isOffline: true, linkedReportActionID: '2'}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + + // When Latest messages is pressed, release the anchor locally instead of waiting for OpenReport. + act(() => result.current.requestLiveTailJump()); + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(params.setTreatAsNoPaginationAnchor).toHaveBeenCalledWith(true); + expect(mockNavigationSetParams).toHaveBeenCalledTimes(isInSidePanel ? 0 : 1); + expect(mockGlobalSetParams).not.toHaveBeenCalled(); + expect(result.current.isScrollToBottomEnabled).toBe(false); + + // Then request the scroll after the cached newest window enters the list. + rerender({...params, linkedReportActionID: '', treatAsNoPaginationAnchor: true, hasNewestReportAction: true}); + await waitFor(() => expect(result.current.isScrollToBottomEnabled).toBe(true)); + }); + + it('waits for the newest page before requesting the Latest messages scroll', async () => { + // Given a historical page, with its linked action still anchoring pagination. + const params = buildParams({linkedReportActionID: 'old-action'}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + + // When Latest messages is pressed repeatedly while its request is pending. + act(() => { + result.current.requestLiveTailJump(); + result.current.requestLiveTailJump(); + }); + expect(mockOpenReport).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(mockNavigationSetParams).not.toHaveBeenCalled(); + + // Then completing the request releases that report's anchor, but still waits for the newest row to render. + const loadedParams = {...params, prevIsLoadingInitialReportActions: true, reportLoadingState: {isLoadingInitialReportActions: false}}; + rerender(loadedParams); + expect(mockNavigationSetParams).toHaveBeenCalledWith({reportActionID: ''}); + expect(mockGlobalSetParams).not.toHaveBeenCalled(); + expect(result.current.isScrollToBottomEnabled).toBe(false); + + rerender({...loadedParams, linkedReportActionID: '', treatAsNoPaginationAnchor: true, hasNewestReportAction: true}); + await act(async () => Promise.resolve()); + expect(result.current.isScrollToBottomEnabled).toBe(true); + act(() => { + result.current.setIsScrollToBottomEnabled(false); + result.current.completeLiveTailPruneAfterScrollToBottom(); + }); + rerender({...loadedParams, linkedReportActionID: '', hasNewestReportAction: true}); + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(mockOpenReport).toHaveBeenCalledTimes(1); + }); + it('requests one post-render scroll once the sent comment is rendered', () => { const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 59f7de5d95e9..042921944da6 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -75,6 +75,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ // --- useReportActionsNewActionLiveTail --- const mockSetIsScrollToBottomEnabled = jest.fn(); const mockCompleteLiveTailPrune = jest.fn(); +const mockRequestLiveTailJump = jest.fn(); let mockIsScrollToBottomEnabled = false; jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ __esModule: true, @@ -82,6 +83,7 @@ jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ isScrollToBottomEnabled: mockIsScrollToBottomEnabled, setIsScrollToBottomEnabled: mockSetIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom: mockCompleteLiveTailPrune, + requestLiveTailJump: mockRequestLiveTailJump, }), })); @@ -772,31 +774,38 @@ describe('useReportActionsScroll', () => { expect(mockOpenReport).not.toHaveBeenCalled(); }); - it('navigates and opens the report when the newest action is not present', async () => { + it('waits for the latest page before scrolling when the newest action is not present', async () => { + // Given a historical page whose newest action has not been loaded. mockLastVisibleActionCreated = '2099-01-01 00:00:00.000'; const {result} = await renderScroll(); + // When the reader presses Latest messages. act(() => { result.current.scrollToBottomAndMarkReportAsRead(); }); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockOpenReport).toHaveBeenCalledTimes(1); - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + // Then the existing catch-up flow owns the eventual scroll, without scrolling the old page first. + expect(mockRequestLiveTailJump).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(mockMarkNewestActionAsRead).not.toHaveBeenCalled(); }); it('does not navigate when a report RHP route is already active', async () => { + // Given a historical page with another report open in the right pane. mockLastVisibleActionCreated = '2099-01-01 00:00:00.000'; mockReportRHPActiveRoute = 'r/1/rhp'; const {result} = await renderScroll(); + // When the reader asks for the latest messages. act(() => { result.current.scrollToBottomAndMarkReportAsRead(); }); + // Then the catch-up request leaves the focused pane in place. expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockOpenReport).toHaveBeenCalledTimes(1); + expect(mockRequestLiveTailJump).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).not.toHaveBeenCalled(); }); }); From 7171251c544e157c0f0114dfc9e26aa5a179b1c8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 2 Nov 2026 16:42:34 +0000 Subject: [PATCH 080/109] fix: keep the unread divider visible after badge jumps Co-authored-by: Codex --- 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 a7b9a5952935..c98c65a651a1 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -390,7 +390,7 @@ function useReportActionsScroll({ setIsFloatingMessageCounterVisible(false); if (unreadMarkerReportActionIndex >= 0) { - reportScrollManager.scrollToIndex(unreadMarkerReportActionIndex); + reportScrollManager.scrollToIndex(unreadMarkerReportActionIndex, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); if (hasNewestReportAction) { markNewestActionAsRead(); } From 6647e3c0f5a679a7dd6c74145833ca1ca287d56d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 2 Nov 2026 16:42:45 +0000 Subject: [PATCH 081/109] fix: defer message badges until list positioning is ready Co-authored-by: Codex --- src/hooks/useReportActionsScroll.ts | 5 +- .../useReportUnreadMessageScrollTracking.ts | 5 ++ tests/unit/useReportActionsScrollTest.tsx | 23 ++++++++- ...seReportUnreadMessageScrollTrackingTest.ts | 50 +++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index c98c65a651a1..255786f8240c 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -199,7 +199,8 @@ function useReportActionsScroll({ } const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey && !shouldScrollToLatestOnOpen; - const [shouldDisablePillTracking, setShouldDisablePillTracking] = useState(!!initialScrollKey); + const [readyListID, setReadyListID] = useState(); + const shouldDisablePillTracking = readyListID !== listID; const { isFloatingMessageCounterVisible, @@ -426,7 +427,7 @@ function useReportActionsScroll({ return; } requestAnimationFrame(() => { - setShouldDisablePillTracking(false); + setReadyListID(listID); updatePillVisibility(); }); }; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 857be7371904..94fc9ba22f18 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -142,6 +142,11 @@ export default function useReportUnreadMessageScrollTracking({ } ref.current.previousViewableItems = viewableItems; + // Initial positioning reports provisional rows behind the cover. Replay the latest viewport when tracking resumes. + if (shouldDisablePillTracking) { + return; + } + const viewableIndexes = viewableItems.map((viewableItem) => viewableItem.index).filter((value) => typeof value === 'number'); if (viewableIndexes.length === 0) { diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 042921944da6..690f12ca245e 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -860,10 +860,28 @@ describe('useReportActionsScroll', () => { }); describe('pill tracking', () => { + it('waits for the current list to finish positioning before tracking pills', async () => { + // Given a read chat whose initial scroll offsets are still estimates. + const params = buildParams(); + const {result, rerender} = await renderScroll(params); + expect(mockShouldDisablePillTracking).toBe(true); + + // When the initial viewport settles, its pill state can reflect the visible messages. + act(() => result.current.onLoad()); + expect(mockShouldDisablePillTracking).toBe(false); + + // Then a replacement list must settle independently of the previous list. + rerender({...params, listID: `${REPORT_ID}:hydrated`}); + expect(mockShouldDisablePillTracking).toBe(true); + act(() => result.current.onLoad()); + expect(mockShouldDisablePillTracking).toBe(false); + }); + it('refreshes pill visibility after the expanded composer collapses', async () => { // Given pill tracking is suspended while the expanded composer covers the report. await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, true); - await renderScroll(); + const {result} = await renderScroll(); + act(() => result.current.onLoad()); mockUpdatePillVisibility.mockClear(); // When the composer collapses and the report becomes visible again. @@ -881,7 +899,8 @@ describe('useReportActionsScroll', () => { // Given the composer at its normal size and no linked message to position // When the hook renders - await renderScroll(); + const {result} = await renderScroll(); + act(() => result.current.onLoad()); // Then scroll-based pill tracking stays enabled so the Latest messages pill follows scrolling expect(mockShouldDisablePillTracking).toBe(false); diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index 914a29973d3d..b11d21073c4b 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -127,6 +127,56 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); + it.each([false, true])('defers unread viewability until initial positioning finishes when inverted is %s', (isInverted) => { + // Given the list is still positioning an unread message outside its provisional viewport. + const onUnreadActionVisible = jest.fn(); + const {result, rerender} = renderHook( + ({shouldDisablePillTracking}) => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: {current: 0}, + onUnreadActionVisible, + onTrackScrolling: jest.fn(), + hasNewerActions: false, + unreadMarkerReportActionIndex: 5, + isInverted, + shouldDisablePillTracking, + }), + {initialProps: {shouldDisablePillTracking: true}}, + ); + + // When provisional viewability arrives before initial positioning settles. + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: isInverted ? 6 : 4, key: 'older-message', isViewable: true, item: {}}], changed: []}); + }); + + // Then the unread pill does not flash over the covered list. + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onUnreadActionVisible).not.toHaveBeenCalled(); + + // When positioning finishes without another viewability event. + rerender({shouldDisablePillTracking: false}); + + // Then the saved viewport exposes the pill because the unread message is still outside it. + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + // When another positioning pass brings the unread message into its covered viewport. + rerender({shouldDisablePillTracking: true}); + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'unread-message', isViewable: true, item: {}}], changed: []}); + }); + + // Then the covered message does not complete read tracking. + expect(onUnreadActionVisible).not.toHaveBeenCalled(); + + // When that viewport becomes ready without another viewability event. + rerender({shouldDisablePillTracking: false}); + + // Then the latest saved viewport completes read tracking and hides the pill. + expect(onUnreadActionVisible).toHaveBeenCalled(); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + }); + it.each([false, true])('ignores viewability events behind another screen and resumes after refocus when inverted is %s', (isInverted) => { // Given the unread marker is outside the viewport and the pill is visible. const onUnreadActionVisible = jest.fn(); From 25b9af9630a302fc7b58a88de62afcbc6e14b96f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 10:21:33 +0000 Subject: [PATCH 082/109] fix: keep visible messages in place when older messages load Co-Authored-By: Claude Opus 5.5 --- ...-pending-anchor-correction-on-scroll.patch | 76 +++++++++++++++++++ patches/@legendapp/list/details.md | 10 +++ 2 files changed, 86 insertions(+) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch create mode 100644 patches/@legendapp/list/details.md diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch new file mode 100644 index 000000000000..5d764ae39f58 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch @@ -0,0 +1,76 @@ +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 4500177..ab986a3 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1927,8 +1927,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { + state.lastScrollDelta = scrollDelta; + const runCalculateItems = () => { + var _a4; ++ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); ++ if (hasPendingMVCPRecalculate) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ } + const calculateItemsParams = { +- doMVCP: scrollingTo !== void 0, ++ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, ++ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, + scrollVelocity + }; + if (isLargeUserScrollJump) { +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 1dab5e5..42e0ccf 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -1906,8 +1906,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { + state.lastScrollDelta = scrollDelta; + const runCalculateItems = () => { + var _a4; ++ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); ++ if (hasPendingMVCPRecalculate) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ } + const calculateItemsParams = { +- doMVCP: scrollingTo !== void 0, ++ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, ++ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, + scrollVelocity + }; + if (isLargeUserScrollJump) { +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 4500177..ab986a3 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1927,8 +1927,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { + state.lastScrollDelta = scrollDelta; + const runCalculateItems = () => { + var _a4; ++ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); ++ if (hasPendingMVCPRecalculate) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ } + const calculateItemsParams = { +- doMVCP: scrollingTo !== void 0, ++ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, ++ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, + scrollVelocity + }; + if (isLargeUserScrollJump) { +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 1dab5e5..42e0ccf 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -1906,8 +1906,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { + state.lastScrollDelta = scrollDelta; + const runCalculateItems = () => { + var _a4; ++ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); ++ if (hasPendingMVCPRecalculate) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ } + const calculateItemsParams = { +- doMVCP: scrollingTo !== void 0, ++ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, ++ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, + scrollVelocity + }; + if (isLargeUserScrollJump) { diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md new file mode 100644 index 000000000000..d01ae733e6da --- /dev/null +++ b/patches/@legendapp/list/details.md @@ -0,0 +1,10 @@ +# `@legendapp/list` patches + +### [@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch](@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch) + +- Reason: On web, the visible messages jump when older messages load. LegendList moves the scroll offset by the estimated height of the new rows. When those rows are measured, it defers the matching correction to the next frame. The scroll event from the first adjustment then repositions the rows with their measured heights but without that correction, so the visible messages jump by the estimate error for one frame and only partly return. When a deferred correction is pending, apply it in that same scroll-triggered pass. +- Scope: Only the web bundles, and only while a deferred item-size correction is pending. Large scroll jumps still cancel the deferred correction before this check, so their behavior is unchanged. +- Upstream PR/issue: 🛑 +- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On web, open a chat with more history than the first page. Scroll to the top until older messages load. The message at the top of the screen should stay in place instead of jumping and snapping back. From 2ff61e1e7235d49e46bd9e847529b6c4e8abfd6a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 10:21:34 +0000 Subject: [PATCH 083/109] fix: estimate report action rows closer to their real height Co-Authored-By: Claude Opus 5.5 --- .../report/ReportActionsList/ReportActionsListContent.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index f7f7700b89fe..7fbd5639332d 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -53,6 +53,9 @@ import useReportActionsListScroll from './useReportActionsListScroll'; import useReportActionsListTelemetry from './useReportActionsListTelemetry'; const REPORT_ACTIONS_DRAW_DISTANCE = 1500; +// Close to a typical message row. Unmeasured rows keep this estimate, and LegendList's 100px default made every +// measurement shift the scroll offset. On web, dragging the scrollbar undoes each shift, which shows as a jump. +const REPORT_ACTIONS_ESTIMATED_ITEM_SIZE = 50; function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListProps) { const styles = useThemeStyles(); @@ -264,6 +267,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct data={listData} renderItem={renderItem} drawDistance={REPORT_ACTIONS_DRAW_DISTANCE} + estimatedItemSize={REPORT_ACTIONS_ESTIMATED_ITEM_SIZE} recycleItems experimental_hideItemsUntilMeasured renderScrollComponent={Platform.OS === 'web' ? undefined : renderActionSheetAwareScrollView} From 2ca717a027d8800a9adce430a6248eebe98674b5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 15:16:23 +0000 Subject: [PATCH 084/109] fix: apply pending list anchor corrections in every position pass Co-Authored-By: Claude Opus 5.5 --- ...-pending-anchor-correction-on-scroll.patch | 76 ----------- ...+001+apply-pending-anchor-correction.patch | 124 ++++++++++++++++++ patches/@legendapp/list/details.md | 6 +- 3 files changed, 127 insertions(+), 79 deletions(-) delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch deleted file mode 100644 index 5d764ae39f58..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch +++ /dev/null @@ -1,76 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 4500177..ab986a3 100644 ---- a/node_modules/@legendapp/list/react-native.web.js -+++ b/node_modules/@legendapp/list/react-native.web.js -@@ -1927,8 +1927,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { - state.lastScrollDelta = scrollDelta; - const runCalculateItems = () => { - var _a4; -+ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); -+ if (hasPendingMVCPRecalculate) { -+ state.scheduledWork.cancel("mvcpRecalculate"); -+ } - const calculateItemsParams = { -- doMVCP: scrollingTo !== void 0, -+ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, -+ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, - scrollVelocity - }; - if (isLargeUserScrollJump) { -diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index 1dab5e5..42e0ccf 100644 ---- a/node_modules/@legendapp/list/react-native.web.mjs -+++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -1906,8 +1906,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { - state.lastScrollDelta = scrollDelta; - const runCalculateItems = () => { - var _a4; -+ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); -+ if (hasPendingMVCPRecalculate) { -+ state.scheduledWork.cancel("mvcpRecalculate"); -+ } - const calculateItemsParams = { -- doMVCP: scrollingTo !== void 0, -+ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, -+ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, - scrollVelocity - }; - if (isLargeUserScrollJump) { -diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 4500177..ab986a3 100644 ---- a/node_modules/@legendapp/list/react.js -+++ b/node_modules/@legendapp/list/react.js -@@ -1927,8 +1927,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { - state.lastScrollDelta = scrollDelta; - const runCalculateItems = () => { - var _a4; -+ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); -+ if (hasPendingMVCPRecalculate) { -+ state.scheduledWork.cancel("mvcpRecalculate"); -+ } - const calculateItemsParams = { -- doMVCP: scrollingTo !== void 0, -+ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, -+ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, - scrollVelocity - }; - if (isLargeUserScrollJump) { -diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index 1dab5e5..42e0ccf 100644 ---- a/node_modules/@legendapp/list/react.mjs -+++ b/node_modules/@legendapp/list/react.mjs -@@ -1906,8 +1906,13 @@ function updateScroll(ctx, newScroll, forceUpdate, options) { - state.lastScrollDelta = scrollDelta; - const runCalculateItems = () => { - var _a4; -+ const hasPendingMVCPRecalculate = state.scheduledWork.has("mvcpRecalculate"); -+ if (hasPendingMVCPRecalculate) { -+ state.scheduledWork.cancel("mvcpRecalculate"); -+ } - const calculateItemsParams = { -- doMVCP: scrollingTo !== void 0, -+ doMVCP: scrollingTo !== void 0 || hasPendingMVCPRecalculate, -+ mvcpAdjustmentSource: hasPendingMVCPRecalculate ? "item-size" : void 0, - scrollVelocity - }; - if (isLargeUserScrollJump) { diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch new file mode 100644 index 000000000000..c49af3ffb4b6 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch @@ -0,0 +1,124 @@ +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 4500177..ff503a5 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -4719,7 +4719,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ let { doMVCP } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 1dab5e5..fac9bd4 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -4698,7 +4698,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ let { doMVCP } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 4500177..ff503a5 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -4719,7 +4719,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ let { doMVCP } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 1dab5e5..fac9bd4 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -4698,7 +4698,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ let { doMVCP } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index d01ae733e6da..11f9c3669d58 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -1,9 +1,9 @@ # `@legendapp/list` patches -### [@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch](@legendapp+list+3.7.1+001+apply-pending-anchor-correction-on-scroll.patch) +### [@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch) -- Reason: On web, the visible messages jump when older messages load. LegendList moves the scroll offset by the estimated height of the new rows. When those rows are measured, it defers the matching correction to the next frame. The scroll event from the first adjustment then repositions the rows with their measured heights but without that correction, so the visible messages jump by the estimate error for one frame and only partly return. When a deferred correction is pending, apply it in that same scroll-triggered pass. -- Scope: Only the web bundles, and only while a deferred item-size correction is pending. Large scroll jumps still cancel the deferred correction before this check, so their behavior is unchanged. +- Reason: On web, LegendList holds a scroll anchor for a moment after a data change and defers the scroll correction for item size changes to the next frame. Any other pass that repositions items before that frame, such as the one a scroll event triggers, moves the visible messages without the correction. They jump for a frame and only partly return. With this patch, the first pass that repositions items applies the pending correction. +- Scope: Only the web bundles, and only while a deferred item-size correction is pending. - Upstream PR/issue: 🛑 - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 - PR introducing patch: https://github.com/Expensify/App/pull/100733 From dd1f12ce8a49ceff674647603e47c1e9c09b57c3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 15:16:37 +0000 Subject: [PATCH 085/109] fix: keep the newest visible messages in place when rows above them resize Co-Authored-By: Claude Opus 5.5 --- ...d-aligned-lists-to-last-visible-item.patch | 136 ++++++++++++++++++ patches/@legendapp/list/details.md | 9 ++ 2 files changed, 145 insertions(+) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch new file mode 100644 index 000000000000..2adf3007608a --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch @@ -0,0 +1,136 @@ +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index ff503a5..b8ecd39 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index fac9bd4..accd9fa 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index ff503a5..b8ecd39 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index fac9bd4..accd9fa 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 11f9c3669d58..d574f459d827 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -8,3 +8,12 @@ - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, open a chat with more history than the first page. Scroll to the top until older messages load. The message at the top of the screen should stay in place instead of jumping and snapping back. + +### [@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch) + +- Reason: When items change size or data changes, LegendList keeps the first visible item in place. In a chat, the oldest loaded message shows the sender's avatar, and it loses that header once older messages from the same sender load. Its height shrinks, so every message below it moves up. An inverted list keeps the bottom in place instead. For lists with `alignItemsAtEnd`, this patch anchors the last visible item, so a size change above it only moves the content above. +- Scope: Only the web bundles, and only lists with `alignItemsAtEnd`. Other lists still anchor the first visible item. +- Upstream PR/issue: 🛑 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On web, clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. From e44e2d4c9e7d4e4bc3929a341a3d38578c70a47b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 15:16:37 +0000 Subject: [PATCH 086/109] fix: anchor older messages that load right after a fast scroll Co-Authored-By: Claude Opus 5.5 --- ...chor-data-changes-after-scroll-jumps.patch | 88 +++++++++++++++++++ patches/@legendapp/list/details.md | 9 ++ 2 files changed, 97 insertions(+) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch new file mode 100644 index 000000000000..8e96d3f5e865 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch @@ -0,0 +1,88 @@ +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index b8ecd39..3d15098 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index accd9fa..0f0be2d 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index b8ecd39..3d15098 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index accd9fa..0f0be2d 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index d574f459d827..b5e8f5de04a6 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -17,3 +17,12 @@ - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. + +### [@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch](@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch) + +- Reason: After a large scroll jump, such as a fast scrollbar drag, LegendList repositions newly measured items without keeping anything in place, so the region the user jumped to can lay out. This lasts until it has measured every item it rendered since the jump. If older messages load in the meantime, their measurements push the visible messages down by a few hundred pixels for a frame. This patch ends that state when the data changes, so the new messages are anchored like any other data change. +- Scope: Only the web bundles, and only lists that keep their visible content in place on data changes. +- Upstream PR/issue: 🛑 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On web, clear the cache and open a chat with a long history. Drag the scrollbar thumb quickly up and down a few times, then hold it near the top until older messages load. The messages on screen should not move. From 78d6dc002e9a66731bb1abd8e62742943a3b22d3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 17:37:29 +0000 Subject: [PATCH 087/109] fix: keep the composer above the keyboard when a chat opens with a draft The keyboard avoiding view set its starting keyboard state from the JS thread on mount. That write reached the UI thread after the keyboard had already opened and reset the view to a closed keyboard, so it never added bottom padding. Apply the upstream fix from kirillzyusko/react-native-keyboard-controller#1685, which sets the starting state on the UI thread and skips it once a keyboard event arrived. Co-Authored-By: Claude Opus 5.5 --- .../details.md | 14 +- ...-initial-keyboard-state-on-ui-thread.patch | 142 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md index cf5fa95cbb7b..e3b43d7058c6 100644 --- a/patches/react-native-keyboard-controller/details.md +++ b/patches/react-native-keyboard-controller/details.md @@ -10,4 +10,16 @@ - Upstream PR/issue: - - E/App issue: https://github.com/Expensify/App/issues/87307 -- PR introducing patch: https://github.com/Expensify/App/pull/87376 \ No newline at end of file +- PR introducing patch: https://github.com/Expensify/App/pull/87376 + +### [react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch](react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch) + +- Reason: + + ``` + KeyboardAvoidingView sets its starting keyboard state from the JS thread when it mounts. Reanimated applies those writes on the UI thread later, once the JS thread is free. When a screen opens with a focused input, such as a chat with a draft, the keyboard can open before that happens. The late writes then reset the view to "keyboard closed, height 0", so it never adds the bottom padding and the composer stays behind the keyboard. The patch applies the starting state on the UI thread and skips it once a keyboard event has arrived. It is the change from the upstream PR below. + ``` + +- Upstream PR/issue: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1685 +- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch new file mode 100644 index 000000000000..e3056c94c1aa --- /dev/null +++ b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch @@ -0,0 +1,142 @@ +diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts +index 64bfa65..0862b6c 100644 +--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts ++++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts +@@ -1,6 +1,6 @@ + import { useLayoutEffect } from "react"; + import { Platform } from "react-native"; +-import { useSharedValue } from "react-native-reanimated"; ++import { runOnUI, useSharedValue } from "react-native-reanimated"; + + import { useKeyboardContext } from "../../context"; + import { useKeyboardHandler } from "../../hooks"; +@@ -14,16 +14,28 @@ export const useKeyboardAnimation = () => { + const height = useSharedValue(0); + const progress = useSharedValue(0); + const isClosed = useSharedValue(true); ++ const hasReceivedEvent = useSharedValue(false); + + useLayoutEffect(() => { +- const initialHeight = -reanimated.height.value; +- const initialProgress = reanimated.progress.value; +- +- // eslint-disable-next-line react-compiler/react-compiler +- heightWhenOpened.value = initialHeight; +- height.value = initialHeight; +- progress.value = initialProgress; +- isClosed.value = initialProgress === 0; ++ // writes from JS are applied on the UI thread asynchronously, so they may ++ // overwrite values set by keyboard events that arrived in between (i. e. when ++ // input has `autoFocus`) - set initial values only if no event arrived yet ++ runOnUI(() => { ++ "worklet"; ++ ++ if (hasReceivedEvent.value) { ++ return; ++ } ++ ++ const initialHeight = -reanimated.height.value; ++ const initialProgress = reanimated.progress.value; ++ ++ // eslint-disable-next-line react-compiler/react-compiler ++ heightWhenOpened.value = initialHeight; ++ height.value = initialHeight; ++ progress.value = initialProgress; ++ isClosed.value = initialProgress === 0; ++ })(); + }, []); + + useKeyboardHandler( +@@ -31,6 +43,8 @@ export const useKeyboardAnimation = () => { + onStart: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; ++ + if (e.height > 0) { + isClosed.value = false; + heightWhenOpened.value = e.height; +@@ -39,20 +53,28 @@ export const useKeyboardAnimation = () => { + onMove: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; + progress.value = e.progress; + height.value = e.height; + }, + onInteractive: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; + progress.value = e.progress; + height.value = e.height; + }, + onEnd: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; + isClosed.value = e.height === 0; + ++ // `onStart` may have been dispatched before this handler was attached ++ if (e.height > 0 && e.progress === 1) { ++ heightWhenOpened.value = e.height; ++ } ++ + height.value = e.height; + progress.value = e.progress; + }, +@@ -67,10 +89,18 @@ export const useTranslateAnimation = () => { + + const padding = useSharedValue(0); + const translate = useSharedValue(0); ++ const hasReceivedEvent = useSharedValue(false); + + useLayoutEffect(() => { +- // eslint-disable-next-line react-compiler/react-compiler +- padding.value = reanimated.progress.value; ++ // see `useKeyboardAnimation` for an explanation ++ runOnUI(() => { ++ "worklet"; ++ ++ if (!hasReceivedEvent.value) { ++ // eslint-disable-next-line react-compiler/react-compiler ++ padding.value = reanimated.progress.value; ++ } ++ })(); + }, []); + + useKeyboardHandler( +@@ -78,6 +108,8 @@ export const useTranslateAnimation = () => { + onStart: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; ++ + if (e.height === 0) { + padding.value = 0; + } +@@ -88,6 +120,8 @@ export const useTranslateAnimation = () => { + onMove: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; ++ + if (OS !== "ios") { + translate.value = e.progress; + } +@@ -95,6 +129,7 @@ export const useTranslateAnimation = () => { + onInteractive: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; + padding.value = 0; + + translate.value = e.progress; +@@ -102,6 +137,7 @@ export const useTranslateAnimation = () => { + onEnd: (e) => { + "worklet"; + ++ hasReceivedEvent.value = true; + padding.value = e.progress; + + if (OS !== "ios") { From 1d22e143dcbe188d1baf8e3ac9f926909ed04c17 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 18:11:16 +0000 Subject: [PATCH 088/109] fix: keep an unread chat at its New divider while rows below it load A chat opened at its unread marker can settle within 250 pixels of the end. The next scroll event then made the list follow the end, so the rows measured below the marker scrolled the New divider out of view a moment after opening. Lists that start at an unread or linked message now follow the end only once they reach it or the user drags near it. Co-Authored-By: Claude Opus 5.5 --- .../useReportActionsListScroll.ts | 8 ++++- tests/ui/ReportActionsListTest.tsx | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index 9c85ea84a1f9..4aa816ef85c8 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -230,6 +230,7 @@ function useReportActionsListScroll({ const lastScrollMetricsRef = useRef(undefined); const endCorrectionFrameRef = useRef(undefined); const userScrolledListIDRef = useRef(undefined); + const userDraggedListIDRef = useRef(undefined); const stopFollowingEnd = () => { userScrolledListIDRef.current = listID; @@ -243,6 +244,7 @@ function useReportActionsListScroll({ }; const handleScrollBeginDrag = () => { + userDraggedListIDRef.current = listID; stopLinkedMessagePositioning(); stopFollowingEnd(); }; @@ -314,7 +316,11 @@ function useReportActionsListScroll({ const previousMetrics = lastScrollMetricsRef.current; if (readyEndListIDRef.current === listID && !hasNewerActions) { if (distanceFromBottom < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD) { - shouldFollowEndOnResizeRef.current = true; + // A list opened at an unread or linked message can settle near the end. Following it from there would + // push that message out of view as rows resize, so wait until the list reaches the end or the user drags. + if (initialScrollIndex === undefined || distanceFromBottom <= 1 || userDraggedListIDRef.current === listID) { + shouldFollowEndOnResizeRef.current = true; + } } else if ( (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || (previousMetrics && diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c916542f3861..632f7c5c9633 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -781,6 +781,41 @@ describe('ReportActionsList (body)', () => { } }); + it('keeps an unread marker in view when the list settles close to the end', async () => { + // Given the chat opens at an unread marker and the list settles 100 pixels above the newest action without the reader scrolling. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll(createMock[0]>({})); + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 1}); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd: false}); + try { + renderReportActionsList(); + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 400}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When rows below the marker are measured and make the content taller. + act(() => getCapturedListProps()?.onContentSizeChange?.(300, 1025)); + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + + // Then the list stays at the marker instead of scrolling it out of view. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + } + }); + it('cancels a queued end correction as soon as the reader starts dragging', async () => { // Given removing the latest message has queued a layout correction at the end. mockUseNetwork.mockReturnValue({isOffline: false}); From 58d907a7441437a8a7160daf856c9ad9512a5d4c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 20:51:18 +0000 Subject: [PATCH 089/109] fix: show the skeleton until a fresh chat loads its newest page After a cache clear, OpenApp stores a few scattered actions for a chat, such as thread parents and its last message, but no page. The chat opened at those old messages and jumped once OpenReport loaded the newest page. While OpenReport is pending for a chat without a loaded page, show the loading skeleton instead. Co-Authored-By: Claude Opus 5.5 --- src/hooks/useReportActionsListModel.ts | 2 + .../inbox/report/ReportActionsListContext.tsx | 12 ++++- tests/unit/ReportActionsListContextTest.ts | 51 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 90f162f078ab..76a0028a674a 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -82,6 +82,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isAppLoadPending = useIsAppLoadPending(); const [reportPaginationState] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE}${reportID}`); + const [hasReportActionPages = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportID}`, {selector: (pages) => !!pages?.length}); const {loadOlderChats, loadNewerChats, currentReportOldestActionID} = useLoadReportActions({ reportID, @@ -125,6 +126,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea isLoadingInitialReportActions, isLoadingApp: isAppLoadPending, reportActionsLength: reportActions.length, + hasReportActionPages, oldestReportActionID: currentReportOldestActionID, hasOlderActions, hasNewerActions, diff --git a/src/pages/inbox/report/ReportActionsListContext.tsx b/src/pages/inbox/report/ReportActionsListContext.tsx index c29fb76a9049..fe5d16ebbc23 100644 --- a/src/pages/inbox/report/ReportActionsListContext.tsx +++ b/src/pages/inbox/report/ReportActionsListContext.tsx @@ -56,6 +56,7 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin isLoadingInitialReportActions, isLoadingApp, reportActionsLength, + hasReportActionPages, oldestUnreadReportAction, isSingleExpenseReport, isMissingReportActions, @@ -102,7 +103,16 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin // actions that arrives right after the initial load shifts the list and breaks the anchor to the linked action. const shouldShowSkeletonForLinkedMessageLoad = !!reportActionIDFromRoute && !isOffline && !hasOnceLoadedReportActions && isReportLoadPending; - const shouldShowInitialSkeleton = shouldShowSkeletonForConciergePanel || shouldShowSkeletonForInitialLoad || shouldShowSkeletonForAppLoad || shouldShowSkeletonForLinkedMessageLoad; + // Actions cached without a loaded page, such as the few that OpenApp returns after a cache clear, are scattered + // across the history. Showing them would open the chat at old messages that jump away once OpenReport loads the newest page. + const shouldShowSkeletonForUnpagedActions = isReportLoadPending && !hasOnceLoadedReportActions && !hasReportActionPages && !report?.pendingFields?.createReport && !isOffline; + + const shouldShowInitialSkeleton = + shouldShowSkeletonForConciergePanel || + shouldShowSkeletonForInitialLoad || + shouldShowSkeletonForAppLoad || + shouldShowSkeletonForLinkedMessageLoad || + shouldShowSkeletonForUnpagedActions; const hasDerivedValueTimingIssue = reportActionsLength > 0 && isMissingReportActions; diff --git a/tests/unit/ReportActionsListContextTest.ts b/tests/unit/ReportActionsListContextTest.ts index fe5b75acacca..8b6f8efe9fed 100644 --- a/tests/unit/ReportActionsListContextTest.ts +++ b/tests/unit/ReportActionsListContextTest.ts @@ -43,6 +43,7 @@ function createReadinessSignals(overrides: Partial { + it('shows the loading skeleton while OpenReport is pending for actions without a loaded page', () => { + // Given a read chat whose only cached actions came without a page, e.g. from OpenApp after a cache clear + const signals = createReadinessSignals({ + report: readReport, + isMissingReportActions: false, + reportActionsLength: 3, + hasReportActionPages: false, + isReportLoadPending: true, + }); + + // When the skeleton state is computed while its OpenReport is pending + const state = computeReportActionsSkeletonState(signals); + + // Then the skeleton shows, so the list never opens at scattered older messages that jump away on load + expect(state.shouldShowLoadingSkeleton).toBe(true); + }); + + it('shows cached actions without a loaded page for an optimistic report', () => { + // Given an optimistic chat that only has local actions and no page yet + const signals = createReadinessSignals({ + report: {...readReport, pendingFields: {createReport: 'add'}}, + isMissingReportActions: false, + reportActionsLength: 1, + hasReportActionPages: false, + isReportLoadPending: true, + }); + + // When the skeleton state is computed while its OpenReport is pending + const state = computeReportActionsSkeletonState(signals); + + // Then the local actions stay visible because they are all there is to show + expect(state.shouldShowLoadingSkeleton).toBe(false); + }); + + it('shows cached actions with a loaded page while OpenReport is pending', () => { + // Given a read chat with a page of cached actions from an earlier visit + const signals = createReadinessSignals({ + report: readReport, + isMissingReportActions: false, + reportActionsLength: 3, + isReportLoadPending: true, + }); + + // When the skeleton state is computed while its OpenReport refreshes the chat + const state = computeReportActionsSkeletonState(signals); + + // Then the cached page shows right away + expect(state.shouldShowLoadingSkeleton).toBe(false); + }); + it('shows the loading skeleton for a pending OpenReport matching this report', () => { const state = computeReportActionsSkeletonState( createReadinessSignals({ From 7d69320b97d6af3724010ca37f7f1a2ee2c84898 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 20:52:18 +0000 Subject: [PATCH 090/109] docs: link the upstream LegendList PRs for the web anchoring patches Co-Authored-By: Claude Opus 5.5 --- patches/@legendapp/list/details.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index b5e8f5de04a6..214469b24c48 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -4,7 +4,7 @@ - Reason: On web, LegendList holds a scroll anchor for a moment after a data change and defers the scroll correction for item size changes to the next frame. Any other pass that repositions items before that frame, such as the one a scroll event triggers, moves the visible messages without the correction. They jump for a frame and only partly return. With this patch, the first pass that repositions items applies the pending correction. - Scope: Only the web bundles, and only while a deferred item-size correction is pending. -- Upstream PR/issue: 🛑 +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/596 - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, open a chat with more history than the first page. Scroll to the top until older messages load. The message at the top of the screen should stay in place instead of jumping and snapping back. @@ -13,7 +13,7 @@ - Reason: When items change size or data changes, LegendList keeps the first visible item in place. In a chat, the oldest loaded message shows the sender's avatar, and it loses that header once older messages from the same sender load. Its height shrinks, so every message below it moves up. An inverted list keeps the bottom in place instead. For lists with `alignItemsAtEnd`, this patch anchors the last visible item, so a size change above it only moves the content above. - Scope: Only the web bundles, and only lists with `alignItemsAtEnd`. Other lists still anchor the first visible item. -- Upstream PR/issue: 🛑 +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/597 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. @@ -22,7 +22,7 @@ - Reason: After a large scroll jump, such as a fast scrollbar drag, LegendList repositions newly measured items without keeping anything in place, so the region the user jumped to can lay out. This lasts until it has measured every item it rendered since the jump. If older messages load in the meantime, their measurements push the visible messages down by a few hundred pixels for a frame. This patch ends that state when the data changes, so the new messages are anchored like any other data change. - Scope: Only the web bundles, and only lists that keep their visible content in place on data changes. -- Upstream PR/issue: 🛑 +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/598 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat with a long history. Drag the scrollbar thumb quickly up and down a few times, then hold it near the top until older messages load. The messages on screen should not move. From 7b38cf64c1e5f86ddd620fdbe956f536b5606678 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Nov 2026 21:25:46 +0000 Subject: [PATCH 091/109] docs: point the keyboard patch notes at its standalone PR Co-Authored-By: Claude Opus 5.5 --- patches/react-native-keyboard-controller/details.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md index e3b43d7058c6..088dfaebd621 100644 --- a/patches/react-native-keyboard-controller/details.md +++ b/patches/react-native-keyboard-controller/details.md @@ -22,4 +22,4 @@ - Upstream PR/issue: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1685 - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 -- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- PR introducing patch: https://github.com/Expensify/App/pull/106003 From f4eabd5cbd8d916253b5db8b9d30fa44c6a58a34 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 5 Nov 2026 09:45:13 +0000 Subject: [PATCH 092/109] fix: show the report skeleton before OpenReport is queued The skeleton for unpaged cached actions only checked the request queue, so the list mounted for a render before ReportFetchHandler queued OpenReport. That mount marked the chat as read, and the list then remounted after the skeleton without its unread marker. Co-Authored-By: Claude Opus 5.5 --- .../inbox/report/ReportActionsListContext.tsx | 4 +++- tests/unit/ReportActionsListContextTest.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsListContext.tsx b/src/pages/inbox/report/ReportActionsListContext.tsx index fe5d16ebbc23..447e816bb66d 100644 --- a/src/pages/inbox/report/ReportActionsListContext.tsx +++ b/src/pages/inbox/report/ReportActionsListContext.tsx @@ -105,7 +105,9 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin // Actions cached without a loaded page, such as the few that OpenApp returns after a cache clear, are scattered // across the history. Showing them would open the chat at old messages that jump away once OpenReport loads the newest page. - const shouldShowSkeletonForUnpagedActions = isReportLoadPending && !hasOnceLoadedReportActions && !hasReportActionPages && !report?.pendingFields?.createReport && !isOffline; + // Gate from the first render, before OpenReport is queued: a list that mounts early marks the chat as read and loses its unread marker on remount. + const shouldShowSkeletonForUnpagedActions = + shouldKeepInitialReportLoadGated && !hasOnceLoadedReportActions && !hasReportActionPages && !report?.pendingFields?.createReport && !isOffline; const shouldShowInitialSkeleton = shouldShowSkeletonForConciergePanel || diff --git a/tests/unit/ReportActionsListContextTest.ts b/tests/unit/ReportActionsListContextTest.ts index 8b6f8efe9fed..0af245ef91a8 100644 --- a/tests/unit/ReportActionsListContextTest.ts +++ b/tests/unit/ReportActionsListContextTest.ts @@ -76,6 +76,24 @@ describe('computeReportActionsSkeletonState', () => { expect(state.shouldShowLoadingSkeleton).toBe(true); }); + it('shows the loading skeleton for actions without a loaded page before OpenReport is queued', () => { + // Given a read chat with unpaged cached actions whose screen has not queued OpenReport yet + const signals = createReadinessSignals({ + report: readReport, + isMissingReportActions: false, + reportActionsLength: 3, + hasReportActionPages: false, + isReportLoadPending: false, + isLoadingInitialReportActions: undefined, + }); + + // When the skeleton state is computed on that first render + const state = computeReportActionsSkeletonState(signals); + + // Then the skeleton already shows, so the list does not mount, mark the chat as read and remount without its unread marker + expect(state.shouldShowLoadingSkeleton).toBe(true); + }); + it('shows cached actions without a loaded page for an optimistic report', () => { // Given an optimistic chat that only has local actions and no page yet const signals = createReadinessSignals({ From 88e699a05402f18570c34983791c76a90fb86f43 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 5 Nov 2026 11:40:32 +0000 Subject: [PATCH 093/109] fix: reveal the LegendList chat only after its initial scroll adjustments LegendList could mark a list ready and show it one frame before the native scroll adjustment for items measured during its initial layout ran. On iOS, a chat that opens at a linked message showed the message at the top and then moved it down by about 45 points. The patch carries LegendApp/legend-list#600 until it is released. Co-Authored-By: Claude Opus 5.5 --- ...ist-after-initial-scroll-adjustments.patch | 240 ++++++++++++++++++ patches/@legendapp/list/details.md | 9 + 2 files changed, 249 insertions(+) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch new file mode 100644 index 000000000000..1af7e2c9a0ec --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch @@ -0,0 +1,240 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index eeb3fb0..d1e3f1c 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -797,9 +797,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -838,7 +835,13 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1232,7 +1235,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index fe5f415..2252796 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -776,9 +776,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -817,7 +814,13 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1211,7 +1214,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 3d15098..72be92e 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -746,7 +743,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1134,7 +1137,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 0f0be2d..5a359ba 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -725,7 +722,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1113,7 +1116,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 3d15098..72be92e 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -746,7 +743,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1134,7 +1137,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 0f0be2d..5a359ba 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -725,7 +722,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1113,7 +1116,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 214469b24c48..901ffa6a8ad8 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -26,3 +26,12 @@ - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat with a long history. Drag the scrollbar thumb quickly up and down a few times, then hold it near the top until older messages load. The messages on screen should not move. + +### [@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch) + +- Reason: Before a list is ready, LegendList moves its items as soon as they are measured but applies the matching native scroll adjustment one frame later. It could mark the list ready, and show it, before that frame ran. On iOS, a chat that opens at a linked message then shows the message at the top of the list and moves it down by about 45 points. With this patch, the list becomes ready only after those adjustments have run. +- Scope: All bundles, and only during a list's initial layout while a scroll adjustment requested before the list was ready is still pending. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/600 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On iOS, open a chat from an LHN row with a Pay or Approve action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. From 7acc7288dd2f9a25ff7b10e1a77c41a91672cd44 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 5 Nov 2026 12:07:21 +0000 Subject: [PATCH 094/109] chore: move the keyboard patch to its own pull request The patch that keeps the message box above the keyboard on Android and iOS also fixes a bug on main, so it ships separately in #106003. Co-Authored-By: Claude Opus 5.5 --- .../details.md | 14 +- ...-initial-keyboard-state-on-ui-thread.patch | 142 ------------------ 2 files changed, 1 insertion(+), 155 deletions(-) delete mode 100644 patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md index 088dfaebd621..cf5fa95cbb7b 100644 --- a/patches/react-native-keyboard-controller/details.md +++ b/patches/react-native-keyboard-controller/details.md @@ -10,16 +10,4 @@ - Upstream PR/issue: - - E/App issue: https://github.com/Expensify/App/issues/87307 -- PR introducing patch: https://github.com/Expensify/App/pull/87376 - -### [react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch](react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch) - -- Reason: - - ``` - KeyboardAvoidingView sets its starting keyboard state from the JS thread when it mounts. Reanimated applies those writes on the UI thread later, once the JS thread is free. When a screen opens with a focused input, such as a chat with a draft, the keyboard can open before that happens. The late writes then reset the view to "keyboard closed, height 0", so it never adds the bottom padding and the composer stays behind the keyboard. The patch applies the starting state on the UI thread and skips it once a keyboard event has arrived. It is the change from the upstream PR below. - ``` - -- Upstream PR/issue: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/1685 -- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 -- PR introducing patch: https://github.com/Expensify/App/pull/106003 +- PR introducing patch: https://github.com/Expensify/App/pull/87376 \ No newline at end of file diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch deleted file mode 100644 index e3056c94c1aa..000000000000 --- a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.22.4+002+apply-initial-keyboard-state-on-ui-thread.patch +++ /dev/null @@ -1,142 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts -index 64bfa65..0862b6c 100644 ---- a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts -+++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/hooks.ts -@@ -1,6 +1,6 @@ - import { useLayoutEffect } from "react"; - import { Platform } from "react-native"; --import { useSharedValue } from "react-native-reanimated"; -+import { runOnUI, useSharedValue } from "react-native-reanimated"; - - import { useKeyboardContext } from "../../context"; - import { useKeyboardHandler } from "../../hooks"; -@@ -14,16 +14,28 @@ export const useKeyboardAnimation = () => { - const height = useSharedValue(0); - const progress = useSharedValue(0); - const isClosed = useSharedValue(true); -+ const hasReceivedEvent = useSharedValue(false); - - useLayoutEffect(() => { -- const initialHeight = -reanimated.height.value; -- const initialProgress = reanimated.progress.value; -- -- // eslint-disable-next-line react-compiler/react-compiler -- heightWhenOpened.value = initialHeight; -- height.value = initialHeight; -- progress.value = initialProgress; -- isClosed.value = initialProgress === 0; -+ // writes from JS are applied on the UI thread asynchronously, so they may -+ // overwrite values set by keyboard events that arrived in between (i. e. when -+ // input has `autoFocus`) - set initial values only if no event arrived yet -+ runOnUI(() => { -+ "worklet"; -+ -+ if (hasReceivedEvent.value) { -+ return; -+ } -+ -+ const initialHeight = -reanimated.height.value; -+ const initialProgress = reanimated.progress.value; -+ -+ // eslint-disable-next-line react-compiler/react-compiler -+ heightWhenOpened.value = initialHeight; -+ height.value = initialHeight; -+ progress.value = initialProgress; -+ isClosed.value = initialProgress === 0; -+ })(); - }, []); - - useKeyboardHandler( -@@ -31,6 +43,8 @@ export const useKeyboardAnimation = () => { - onStart: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; -+ - if (e.height > 0) { - isClosed.value = false; - heightWhenOpened.value = e.height; -@@ -39,20 +53,28 @@ export const useKeyboardAnimation = () => { - onMove: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; - progress.value = e.progress; - height.value = e.height; - }, - onInteractive: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; - progress.value = e.progress; - height.value = e.height; - }, - onEnd: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; - isClosed.value = e.height === 0; - -+ // `onStart` may have been dispatched before this handler was attached -+ if (e.height > 0 && e.progress === 1) { -+ heightWhenOpened.value = e.height; -+ } -+ - height.value = e.height; - progress.value = e.progress; - }, -@@ -67,10 +89,18 @@ export const useTranslateAnimation = () => { - - const padding = useSharedValue(0); - const translate = useSharedValue(0); -+ const hasReceivedEvent = useSharedValue(false); - - useLayoutEffect(() => { -- // eslint-disable-next-line react-compiler/react-compiler -- padding.value = reanimated.progress.value; -+ // see `useKeyboardAnimation` for an explanation -+ runOnUI(() => { -+ "worklet"; -+ -+ if (!hasReceivedEvent.value) { -+ // eslint-disable-next-line react-compiler/react-compiler -+ padding.value = reanimated.progress.value; -+ } -+ })(); - }, []); - - useKeyboardHandler( -@@ -78,6 +108,8 @@ export const useTranslateAnimation = () => { - onStart: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; -+ - if (e.height === 0) { - padding.value = 0; - } -@@ -88,6 +120,8 @@ export const useTranslateAnimation = () => { - onMove: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; -+ - if (OS !== "ios") { - translate.value = e.progress; - } -@@ -95,6 +129,7 @@ export const useTranslateAnimation = () => { - onInteractive: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; - padding.value = 0; - - translate.value = e.progress; -@@ -102,6 +137,7 @@ export const useTranslateAnimation = () => { - onEnd: (e) => { - "worklet"; - -+ hasReceivedEvent.value = true; - padding.value = e.progress; - - if (OS !== "ios") { From e5c7317d7191b36bb16bebd96834febad16ee163 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 10:52:23 +0100 Subject: [PATCH 095/109] fix: keep a linked report preview in place while newer pages load A report preview counted as the newest action even on a historical page. Each newer page merged below a linked preview then scrolled the list to its end, which requested the next page and repeated until the latest page. Co-Authored-By: Claude Opus 5.5 --- src/hooks/useReportActionsScroll.ts | 4 +++- tests/unit/useReportActionsScrollTest.tsx | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 5e5a03ec68c4..cd11e1d8bfc9 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -160,7 +160,9 @@ function useReportActionsScroll({ const lastAction = sortedVisibleReportActions.at(0); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); - const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); + // A report preview can be the newest loaded action of a historical page. While newer pages are missing, loading + // the next one must not read as a new message, or each page would scroll to its end and request another. + const hasNewestReportAction = !hasNewerActions && (lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction)); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = Object.fromEntries(sortedVisibleReportActions.map((action) => [action.reportActionID, action])); const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 9d72e7448ccf..6f74495b74ab 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -749,6 +749,24 @@ describe('useReportActionsScroll', () => { // Then it follows only inside the same 250-pixel range used before the migration. expect(mockScrollToBottom).toHaveBeenCalledTimes(shouldFollow ? 1 : 0); }); + + it('does not follow a newer page loaded below a linked report preview', async () => { + // Given a linked report preview is the newest loaded action of a historical page, at the bottom of the list. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockIsReportPreviewAction = true; + mockLastVisibleActionCreated = '2099-01-01 00:00:00.000'; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {rerender} = await renderScroll(buildParams({hasNewerActions: true, sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]})); + mockScrollToBottom.mockClear(); + + // When the next newer page, which also ends with a report preview, is merged below it. + const newerAction = makeAction('2'); + rerender(buildParams({hasNewerActions: true, sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + await waitForBatchedUpdatesWithAct(); + + // Then the page merge is not treated as a new message, so the list does not jump and request another page. + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); }); describe('scrollToBottomAndMarkReportAsRead', () => { From 41299d139200039d5c779a498cb23ca8746cb25a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 11:13:29 +0100 Subject: [PATCH 096/109] fix: open a linked final message at the top while newer messages load OpenReport returns a linked action with older actions only, so it was the last row and opened at the bottom of the list. A skeleton now holds the space below it until the newest page loads, so the link opens at the usual offset and newer rows fill in below it. The list requests the first newer page itself, because the skeleton can keep the end outside the pagination threshold. Co-Authored-By: Claude Opus 5.5 --- src/hooks/useReportActionsScroll.ts | 21 ++++++++++++---- .../ReportActionsListContent.tsx | 2 ++ .../useReportActionsListScroll.ts | 21 +++++++++++++++- tests/unit/useReportActionsScrollTest.tsx | 25 +++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index cd11e1d8bfc9..72a919413ab9 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -116,6 +116,9 @@ type UseReportActionsScrollResult = { /** Positioning params (viewPosition/viewOffset) paired with initialScrollIndex */ initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; + /** Whether to show a skeleton below a linked final action while newer actions are still to load */ + shouldShowNewerActionsSkeleton: boolean; + /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; @@ -334,6 +337,16 @@ function useReportActionsScroll({ const targetIndex = initialScrollKey ? (reportActionIDToDisplayIndex?.get(initialScrollKey) ?? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey)) : -1; + const isLinkedTargetLastRow = !!linkedReportActionID && initialScrollKey === linkedReportActionID && targetIndex >= 0 && targetIndex === renderedVisibleReportActions.length - 1; + // OpenReport returns a linked action with older actions only. The list shows a skeleton below it until the newest + // page loads, so the link can open near the top like any other instead of at the end of a historical page. Removing + // the skeleton with an intermediate page would shrink the content before LegendList sizes the new rows. + const [newerActionsSkeletonListID, setNewerActionsSkeletonListID] = useState(); + if (isLinkedTargetLastRow && hasNewerActions && newerActionsSkeletonListID !== listID) { + setNewerActionsSkeletonListID(listID); + } + const shouldShowNewerActionsSkeleton = hasNewerActions && (isLinkedTargetLastRow || newerActionsSkeletonListID === listID); + const isLinkedLastAction = isLinkedTargetLastRow && !hasNewerActions; let initialScrollIndex: number | undefined; let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; if (targetIndex >= 0) { @@ -341,10 +354,7 @@ function useReportActionsScroll({ // A linked final action can be taller than the viewport, so show its highlighted end. // Unread actions still start at the New marker. Omit viewOffset for bottom alignment so // LegendList includes the footer (for example, Concierge's thinking indicator). - initialScrollIndexParams = - initialScrollKey === linkedReportActionID && targetIndex === renderedVisibleReportActions.length - 1 - ? {viewPosition: 1} - : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + initialScrollIndexParams = isLinkedLastAction ? {viewPosition: 1} : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { initialScrollIndex = 0; } @@ -358,7 +368,7 @@ function useReportActionsScroll({ listID, linkedReportActionID: initialScrollKey === linkedReportActionID ? linkedReportActionID : undefined, targetIndex, - isLastAction: targetIndex === renderedVisibleReportActions.length - 1, + isLastAction: isLinkedLastAction, }); useScrollToEndOnNewMessageReceived({ @@ -430,6 +440,7 @@ function useReportActionsScroll({ shouldBeAlignedToTop, initialScrollIndex, initialScrollIndexParams, + shouldShowNewerActionsSkeleton, onLoad, onItemSizeChanged, trackLinkedMessageScroll, diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index 162297b8d5dd..68daf4be4f06 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -154,6 +154,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) legendListProps, loadedInitialViewportListID, shouldShowInitialViewportSkeleton, + shouldShowNewerActionsSkeleton, shouldBeAlignedToTop, onShowPreviousMessages, isFloatingMessageCounterVisible, @@ -208,6 +209,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) reportID={reportID} isDraftPendingCompletion={isDraftPendingCompletion} /> + {shouldShowNewerActionsSkeleton && } ); diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index 4681dd809e78..8e674b3aeead 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -20,7 +20,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {LegendListRef, OnViewableItemsChangedInfo} from '@legendapp/list/react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import {useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useImperativeHandle, useRef, useState} from 'react'; import type {ReportActionsListScrollMetrics, UseReportActionsListScrollParams} from './types'; @@ -194,6 +194,7 @@ function useReportActionsListScroll({ shouldBeAlignedToTop, initialScrollIndex, initialScrollIndexParams, + shouldShowNewerActionsSkeleton, onLoad, onItemSizeChanged, trackLinkedMessageScroll, @@ -364,6 +365,23 @@ function useReportActionsListScroll({ }); }; + // The skeleton below a linked final action can keep the list end outside onEndReachedThreshold once the link sits at + // the top, so request the newer page directly unless LegendList already did. + const loadNewerChatsBelowLinkedAction = useEffectEvent(() => { + if (legendListRef.current?.getState()?.isEndReached) { + return; + } + loadNewerChatsAfterTransitions(); + }); + const isInitialViewportLoaded = loadedInitialViewportListID === listID; + useEffect(() => { + if (!isInitialViewportLoaded || !shouldShowNewerActionsSkeleton) { + return; + } + const frame = requestAnimationFrame(loadNewerChatsBelowLinkedAction); + return () => cancelAnimationFrame(frame); + }, [isInitialViewportLoaded, shouldShowNewerActionsSkeleton]); + useFollowActionBadgeTarget({ reportID, actionTargetReportActionID: actionBadgeTargetID, @@ -394,6 +412,7 @@ function useReportActionsListScroll({ listID, loadedInitialViewportListID, shouldShowInitialViewportSkeleton, + shouldShowNewerActionsSkeleton, shouldBeAlignedToTop, onShowPreviousMessages, isFloatingMessageCounterVisible, diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 6f74495b74ab..d95f4a80556a 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -317,6 +317,31 @@ describe('useReportActionsScroll', () => { expect(result.current.initialScrollIndex).toBe(0); expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 1}); + expect(result.current.shouldShowNewerActionsSkeleton).toBe(false); + }); + + it('top-aligns a final linked report action above a skeleton until the newest page loads', async () => { + // Given OpenReport returned the linked action as the newest action of a historical page. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result, rerender} = await renderScroll({hasNewerActions: true, sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); + + // Then it opens at the usual linked offset, with a skeleton holding the space for the newer page. + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(result.current.shouldShowNewerActionsSkeleton).toBe(true); + + // When an intermediate newer page loads below it. + const newerAction = makeAction('2'); + rerender(buildParams({hasNewerActions: true, sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + + // Then the skeleton stays, so the content below the link does not shrink while the new rows are sized. + expect(result.current.shouldShowNewerActionsSkeleton).toBe(true); + + // When the newest page loads. + rerender(buildParams({sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + + // Then the skeleton is removed. + expect(result.current.shouldShowNewerActionsSkeleton).toBe(false); }); it('prefers a linked action over a later unread marker', async () => { From 062f19883ed20b6cdc1b5df5e05cd25fa2ef3b38 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 11:13:29 +0100 Subject: [PATCH 097/109] fix: handle development API proxy failures Identify the development proxy consistently and finish failed upstream requests with a gateway error. Co-authored-by: Codex --- tests/tooling/webProxy.test.ts | 94 ++++++++++++++++++++++++++++++++++ web/proxy.ts | 10 +++- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/tooling/webProxy.test.ts diff --git a/tests/tooling/webProxy.test.ts b/tests/tooling/webProxy.test.ts new file mode 100644 index 000000000000..8a5768054513 --- /dev/null +++ b/tests/tooling/webProxy.test.ts @@ -0,0 +1,94 @@ +/** Exercises the development API proxy against a local upstream, including failed connections. */ +import {afterAll, beforeAll, describe, expect, mock, test} from 'bun:test'; + +import type {RequestOptions} from 'https'; + +import {once} from 'events'; +import http from 'http'; + +const createServer = http.createServer; +const request = http.request; +let upstream: http.Server; +let proxy: http.Server | undefined; +let origin: string; +let receivedUserAgent: string | string[] | undefined; + +beforeAll(async () => { + upstream = createServer((incoming, response) => { + if (incoming.url === '/reset') { + incoming.socket.destroy(); + return; + } + receivedUserAgent = incoming.headers['user-agent']; + response.end('upstream-ok'); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === 'string') { + throw new Error('The local upstream must listen on a TCP port.'); + } + + const captureServer = (listener: http.RequestListener) => { + proxy = createServer(listener); + return proxy; + }; + mock.module('http', () => ({default: {createServer: captureServer}, createServer: captureServer})); + const forwardLocally = (options: RequestOptions) => request({...options, protocol: 'http:', hostname: '127.0.0.1', port: upstreamAddress.port}); + mock.module('https', () => ({default: {request: forwardLocally}, request: forwardLocally})); + mock.module('../../web/cloudflare-worker/src/developmentAPIProxy', () => ({API_PROXY_PORT: 0})); + // The proxy has its own listener; use an ephemeral port without changing another dev session. + process.env.USE_WEB_PROXY = 'true'; + await import('../../web/proxy'); + if (!proxy) { + throw new Error('The development API proxy did not start.'); + } + if (!proxy.listening) { + await once(proxy, 'listening'); + } + const address = proxy.address(); + if (!address || typeof address === 'string') { + throw new Error('The development API proxy must listen on a TCP port.'); + } + origin = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + for (const server of [proxy, upstream]) { + if (!server) { + continue; + } + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + mock.restore(); +}); + +describe('web API proxy', () => { + test('identifies the proxy instead of forwarding the browser identity', async () => { + // Given a browser identity that must not describe the proxy connection to the backend. + const browserUserAgent = 'test-browser/1.0'; + + // When a real request passes through the proxy to a working local upstream. + const response = await fetch(`${origin}/api/Ping`, {headers: new Headers([['User-Agent', browserUserAgent]]), signal: AbortSignal.timeout(1000)}); + + // Then the proxy identifies itself and preserves the successful response. + expect(response.status).toBe(200); + expect(await response.text()).toBe('upstream-ok'); + expect(receivedUserAgent).toBe('Development-NewDot/1.0'); + }); + + test('finishes the downstream response when the upstream connection resets', async () => { + // Given an upstream that closes its socket before sending response headers. + const url = `${origin}/reset`; + + // When the browser requests a response through the real proxy handler. + const response = await fetch(url, {signal: AbortSignal.timeout(1000)}); + + // Then it receives a gateway error instead of waiting indefinitely. + expect(response.status).toBe(502); + expect(await response.text()).toBe(''); + }); +}); diff --git a/web/proxy.ts b/web/proxy.ts index b0133af7da5e..1f9738728472 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -72,8 +72,9 @@ const server = http.createServer((request: IncomingMessage, response: ServerResp headers: { ...request.headers, host: hostname, + // Identify the development proxy so upstream checks match the actual HTTP client. // eslint-disable-next-line @typescript-eslint/naming-convention - 'user-agent': request.headers['user-agent']?.concat(' Development-NewDot/1.0'), + 'user-agent': 'Development-NewDot/1.0', }, port: 443, }); @@ -87,6 +88,13 @@ const server = http.createServer((request: IncomingMessage, response: ServerResp proxyRequest.on('error', (error) => { /* eslint-disable-next-line no-console */ console.log(error); + // A failed upstream connection must not leave the browser waiting indefinitely. + if (response.headersSent) { + response.destroy(error); + return; + } + response.writeHead(502); + response.end(); }); }); From 1f4acb7c969038436d28b08f26e786b2b747fb07 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 11:58:52 +0100 Subject: [PATCH 098/109] chore: upgrade LegendList to 3.7.2 and port its patches Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 8 +- package.json | 2 +- ...001+apply-pending-anchor-correction.patch} | 76 +++++++------------ ...-aligned-lists-to-last-visible-item.patch} | 68 +++++------------ ...hor-data-changes-after-scroll-jumps.patch} | 48 ++---------- ...st-after-initial-scroll-adjustments.patch} | 48 ++++++------ patches/@legendapp/list/details.md | 8 +- 7 files changed, 89 insertions(+), 169 deletions(-) rename patches/@legendapp/list/{@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch => @legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch} (72%) rename patches/@legendapp/list/{@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch => @legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch} (73%) rename patches/@legendapp/list/{@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch => @legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch} (68%) rename patches/@legendapp/list/{@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch => @legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch} (85%) diff --git a/package-lock.json b/package-lock.json index a0000e65bc43..5b7a39e9b131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.1", + "@legendapp/list": "^3.7.2", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11729,9 +11729,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.1.tgz", - "integrity": "sha512-zgIy84yG2JP93+kib61rZBTcYGZ6fULrkCQz/MF7WrjVJeJQa/rR4z+rXfM5oveirO5mYkro4IDGytARIjJe9w==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.2.tgz", + "integrity": "sha512-StFy5qjOHHuxHKjenwLq500S/z+zuF43hj8iqXhX9EMtzeOagPfsnMRWphvBG4CYBvMTX6VzgLjN5BzzwY1jDA==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index b422b1a3e2e0..756b643927b3 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.1", + "@legendapp/list": "^3.7.2", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch similarity index 72% rename from patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch index c49af3ffb4b6..19a3139e8df6 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch @@ -1,124 +1,104 @@ -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 4500177..ff503a5 100644 +index 5155454..c102661 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -4719,7 +4719,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4843,7 +4843,8 @@ function calculateItemsInView(ctx, params = {}) { const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); - const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ let { doMVCP } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; const prevNumContainers = peek$(ctx, "numContainers"); -@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4943,6 +4944,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index 1dab5e5..fac9bd4 100644 +index ce08abe..ff71fd6 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -4698,7 +4698,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4822,7 +4822,8 @@ function calculateItemsInView(ctx, params = {}) { const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); - const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ let { doMVCP } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; const prevNumContainers = peek$(ctx, "numContainers"); -@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4922,6 +4923,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 4500177..ff503a5 100644 +index 5155454..c102661 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -4719,7 +4719,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4843,7 +4843,8 @@ function calculateItemsInView(ctx, params = {}) { const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); - const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ let { doMVCP } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; const prevNumContainers = peek$(ctx, "numContainers"); -@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4943,6 +4944,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index 1dab5e5..fac9bd4 100644 +index ce08abe..ff71fd6 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -4698,7 +4698,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4822,7 +4822,8 @@ function calculateItemsInView(ctx, params = {}) { const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); - const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ const { dataChanged, forceFullItemPositions, mvcpAdjustmentSource } = params; -+ let { doMVCP } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; const prevNumContainers = peek$(ctx, "numContainers"); -@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4922,6 +4923,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch similarity index 73% rename from patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch index 2adf3007608a..bad48a60d7d7 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch @@ -1,25 +1,18 @@ -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index ff503a5..b8ecd39 100644 +index c102661..99cac83 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { - targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); + targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); @@ -29,31 +22,24 @@ index ff503a5..b8ecd39 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index fac9bd4..accd9fa 100644 +index ff71fd6..a7a89dd 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { - targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); + targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); @@ -63,31 +49,24 @@ index fac9bd4..accd9fa 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index ff503a5..b8ecd39 100644 +index c102661..99cac83 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { - targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); + targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); @@ -97,31 +76,24 @@ index ff503a5..b8ecd39 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index fac9bd4..accd9fa 100644 +index ff71fd6..a7a89dd 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { - targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); + targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); @@ -131,6 +103,6 @@ index fac9bd4..accd9fa 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch similarity index 68% rename from patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch index 8e96d3f5e865..86a890e5d799 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch @@ -1,86 +1,54 @@ -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index b8ecd39..3d15098 100644 +index 99cac83..30d9757 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + state.userScrollAnchorReset = void 0; + } calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index accd9fa..0f0be2d 100644 +index a7a89dd..f55dea9 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + state.userScrollAnchorReset = void 0; + } calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index b8ecd39..3d15098 100644 +index 99cac83..30d9757 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + state.userScrollAnchorReset = void 0; + } calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index accd9fa..0f0be2d 100644 +index a7a89dd..f55dea9 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + state.userScrollAnchorReset = void 0; + } calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch similarity index 85% rename from patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch index 1af7e2c9a0ec..3ced246136c8 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index eeb3fb0..d1e3f1c 100644 +index 4af685d..8fd2025 100644 --- a/node_modules/@legendapp/list/react-native.js +++ b/node_modules/@legendapp/list/react-native.js -@@ -797,9 +797,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1063,9 +1063,6 @@ function requestAdjust(ctx, positionDiff, source) { ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); } else { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -12,7 +12,7 @@ index eeb3fb0..d1e3f1c 100644 } }; state.scroll += positionDiff; -@@ -838,7 +835,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1104,7 +1101,13 @@ function requestAdjust(ctx, positionDiff, source) { } } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -27,7 +27,7 @@ index eeb3fb0..d1e3f1c 100644 } } } -@@ -1232,7 +1235,9 @@ function setInitialRenderState(ctx, { +@@ -1529,7 +1532,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -39,10 +39,10 @@ index eeb3fb0..d1e3f1c 100644 set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index fe5f415..2252796 100644 +index 9ebfddc..53fb116 100644 --- a/node_modules/@legendapp/list/react-native.mjs +++ b/node_modules/@legendapp/list/react-native.mjs -@@ -776,9 +776,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1042,9 +1042,6 @@ function requestAdjust(ctx, positionDiff, source) { ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); } else { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -52,7 +52,7 @@ index fe5f415..2252796 100644 } }; state.scroll += positionDiff; -@@ -817,7 +814,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1083,7 +1080,13 @@ function requestAdjust(ctx, positionDiff, source) { } } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -67,7 +67,7 @@ index fe5f415..2252796 100644 } } } -@@ -1211,7 +1214,9 @@ function setInitialRenderState(ctx, { +@@ -1508,7 +1511,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -79,10 +79,10 @@ index fe5f415..2252796 100644 set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 3d15098..72be92e 100644 +index 30d9757..b782503 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -92,7 +92,7 @@ index 3d15098..72be92e 100644 } }; state.scroll += positionDiff; -@@ -746,7 +743,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -107,7 +107,7 @@ index 3d15098..72be92e 100644 } } } -@@ -1134,7 +1137,9 @@ function setInitialRenderState(ctx, { +@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -119,10 +119,10 @@ index 3d15098..72be92e 100644 set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index 0f0be2d..5a359ba 100644 +index f55dea9..a1bcd90 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -132,7 +132,7 @@ index 0f0be2d..5a359ba 100644 } }; state.scroll += positionDiff; -@@ -725,7 +722,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -147,7 +147,7 @@ index 0f0be2d..5a359ba 100644 } } } -@@ -1113,7 +1116,9 @@ function setInitialRenderState(ctx, { +@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -159,10 +159,10 @@ index 0f0be2d..5a359ba 100644 set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 3d15098..72be92e 100644 +index 30d9757..b782503 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -172,7 +172,7 @@ index 3d15098..72be92e 100644 } }; state.scroll += positionDiff; -@@ -746,7 +743,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -187,7 +187,7 @@ index 3d15098..72be92e 100644 } } } -@@ -1134,7 +1137,9 @@ function setInitialRenderState(ctx, { +@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -199,10 +199,10 @@ index 3d15098..72be92e 100644 set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index 0f0be2d..5a359ba 100644 +index f55dea9..a1bcd90 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -212,7 +212,7 @@ index 0f0be2d..5a359ba 100644 } }; state.scroll += positionDiff; -@@ -725,7 +722,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -227,7 +227,7 @@ index 0f0be2d..5a359ba 100644 } } } -@@ -1113,7 +1116,9 @@ function setInitialRenderState(ctx, { +@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 901ffa6a8ad8..4889cf76a52a 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -1,6 +1,6 @@ # `@legendapp/list` patches -### [@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch) +### [@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch) - Reason: On web, LegendList holds a scroll anchor for a moment after a data change and defers the scroll correction for item size changes to the next frame. Any other pass that repositions items before that frame, such as the one a scroll event triggers, moves the visible messages without the correction. They jump for a frame and only partly return. With this patch, the first pass that repositions items applies the pending correction. - Scope: Only the web bundles, and only while a deferred item-size correction is pending. @@ -9,7 +9,7 @@ - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, open a chat with more history than the first page. Scroll to the top until older messages load. The message at the top of the screen should stay in place instead of jumping and snapping back. -### [@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch) +### [@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch) - Reason: When items change size or data changes, LegendList keeps the first visible item in place. In a chat, the oldest loaded message shows the sender's avatar, and it loses that header once older messages from the same sender load. Its height shrinks, so every message below it moves up. An inverted list keeps the bottom in place instead. For lists with `alignItemsAtEnd`, this patch anchors the last visible item, so a size change above it only moves the content above. - Scope: Only the web bundles, and only lists with `alignItemsAtEnd`. Other lists still anchor the first visible item. @@ -18,7 +18,7 @@ - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. -### [@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch](@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch) +### [@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch](@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch) - Reason: After a large scroll jump, such as a fast scrollbar drag, LegendList repositions newly measured items without keeping anything in place, so the region the user jumped to can lay out. This lasts until it has measured every item it rendered since the jump. If older messages load in the meantime, their measurements push the visible messages down by a few hundred pixels for a frame. This patch ends that state when the data changes, so the new messages are anchored like any other data change. - Scope: Only the web bundles, and only lists that keep their visible content in place on data changes. @@ -27,7 +27,7 @@ - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat with a long history. Drag the scrollbar thumb quickly up and down a few times, then hold it near the top until older messages load. The messages on screen should not move. -### [@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch) +### [@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch) - Reason: Before a list is ready, LegendList moves its items as soon as they are measured but applies the matching native scroll adjustment one frame later. It could mark the list ready, and show it, before that frame ran. On iOS, a chat that opens at a linked message then shows the message at the top of the list and moves it down by about 45 points. With this patch, the list becomes ready only after those adjustments have run. - Scope: All bundles, and only during a list's initial layout while a scroll adjustment requested before the list was ready is still pending. From 815595ea10a1991e39a4106897a6012f9bb90175 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 12:07:08 +0100 Subject: [PATCH 099/109] fix: keep LegendList's native scroll adjustments precise LegendList anchors native scroll adjustments to a view placed 10,000,000 points down. Native layout stores positions as 32-bit floats, which hold that value only to whole points, so each adjustment was rounded while the items moved by the exact amount. On Android, a chat opened at a linked message moved up by 1-2 px when the rows above it were measured. Patch the anchor down to 1,000,000 points, as proposed upstream in LegendApp/legend-list#602. Co-Authored-By: Claude Opus 5.5 --- ...05+keep-scroll-adjust-anchor-precise.patch | 30 +++++++++++++++++++ patches/@legendapp/list/details.md | 9 ++++++ 2 files changed, 39 insertions(+) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch new file mode 100644 index 000000000000..8c8072e4d7a7 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch @@ -0,0 +1,30 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -6636,7 +6636,10 @@ + function ScrollAdjust() { + var _a3; + const ctx = useStateContext(); +- const bias = 1e7; ++ // The bias keeps this anchor below the scroll offset, so native maintainVisibleContentPosition always anchors to it. ++ // Native layout stores positions as 32-bit floats, which hold 10,000,000 only to the nearest whole point. That ++ // rounded each adjustment by up to half a point, and the visible items moved by the difference. ++ const bias = 1e6; + const [scrollAdjust, scrollAdjustUserOffset] = useArr$(["scrollAdjust", "scrollAdjustUserOffset"]); + const scrollOffset = (scrollAdjust || 0) + (scrollAdjustUserOffset || 0) + bias; + const horizontal = !!((_a3 = ctx.state) == null ? void 0 : _a3.props.horizontal); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -6615,7 +6615,10 @@ + function ScrollAdjust() { + var _a3; + const ctx = useStateContext(); +- const bias = 1e7; ++ // The bias keeps this anchor below the scroll offset, so native maintainVisibleContentPosition always anchors to it. ++ // Native layout stores positions as 32-bit floats, which hold 10,000,000 only to the nearest whole point. That ++ // rounded each adjustment by up to half a point, and the visible items moved by the difference. ++ const bias = 1e6; + const [scrollAdjust, scrollAdjustUserOffset] = useArr$(["scrollAdjust", "scrollAdjustUserOffset"]); + const scrollOffset = (scrollAdjust || 0) + (scrollAdjustUserOffset || 0) + bias; + const horizontal = !!((_a3 = ctx.state) == null ? void 0 : _a3.props.horizontal); diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 4889cf76a52a..5865a4976761 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -35,3 +35,12 @@ - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On iOS, open a chat from an LHN row with a Pay or Approve action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. + +### [@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch](@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch) + +- Reason: LegendList keeps the visible items in place with a hidden anchor view placed 10,000,000 points down. Native layout stores positions as 32-bit floats, which hold that value only to the nearest whole point, so each scroll adjustment was rounded while the items moved by the exact amount. On Android, a chat that opens at a linked message moved up by 1–2 px when the rows above it were measured. This patch moves the anchor to 1,000,000 points, where its position stays precise to 1/16 of a point. +- Scope: Only the native bundles. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/602 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On Android, open a chat from an LHN row with a Fix action, so it opens at the linked expense. When the Latest messages button appears, the messages on screen should not move. From 20d91d505931f2158120aac85bb1b6115f424c0a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 14:16:42 +0100 Subject: [PATCH 100/109] fix: open a linked chat at its offset on Android without moving it On Android, a chat opened at a linked message appeared with the message at the top of the list and then moved down to its offset. LegendList applied the native anchor adjustments for rows measured during its initial scroll a second time, and its retries reused a stale target. Patch LegendList to skip anchor moves a later native scroll already includes, move the pending target with each adjustment, and finish its silent-scroll retry before the scroll completes (LegendApp/legend-list#604). The list also requested older actions from scroll events that arrive before the initial scroll lands, which inserted a page above the linked message while it was being positioned. Older actions now load from scroll events only once the list is ready. Co-Authored-By: Claude Opus 5.5 --- ...keep-native-initial-scroll-on-target.patch | 228 ++++++++++++++++++ patches/@legendapp/list/details.md | 9 + .../useReportActionsListScroll.ts | 8 +- tests/ui/ReportActionsListTest.tsx | 36 +++ 4 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch new file mode 100644 index 000000000000..2bc501906ab1 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch @@ -0,0 +1,228 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -518,7 +518,9 @@ + var ENABLE_DEBUG_VIEW = IS_DEV && false; + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -1051,6 +1053,20 @@ + } + + // src/utils/requestAdjust.ts ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ const scrollingTo = state.scrollingTo; ++ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ scrollingTo.offset += positionDiff; ++ if (scrollingTo.targetOffset !== void 0) { ++ scrollingTo.targetOffset += positionDiff; ++ } ++ const watchdog = initialScrollWatchdog.get(state); ++ if (watchdog) { ++ watchdog.targetOffset += positionDiff; ++ } ++} + function requestAdjust(ctx, positionDiff, source) { + var _a3; + const state = ctx.state; +@@ -1070,6 +1086,7 @@ + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state, positionDiff); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); +@@ -1101,8 +1118,11 @@ + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ const nativeScrollEpoch = state.nativeScrollEpoch; + requestAnimationFrame(() => { +- doit(); ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; + if (!state.adjustingFromInitialMount) { + setInitialRenderState(ctx, {}); +@@ -2435,9 +2455,10 @@ + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_d = state.silentInitialScrollReturn) == null ? void 0 : _d.call(state); + cancelScrollCompletionChecks(state); + const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { +@@ -2558,7 +2579,10 @@ + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; ++ var _a3, _b; ++ if (Platform.OS !== "web") { ++ ctx.state.nativeScrollEpoch = ((_b = ctx.state.nativeScrollEpoch) != null ? _b : 0) + 1; ++ } + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { +@@ -2608,10 +2632,13 @@ + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ const returnToTarget = () => { ++ var _a4, _b2; ++ state.silentInitialScrollReturn = void 0; ++ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); ++ }; ++ state.silentInitialScrollReturn = returnToTarget; ++ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +@@ -2640,6 +2667,7 @@ + + // src/core/doScrollTo.native.ts + function doScrollTo(ctx, params) { ++ var _nativeScrollEpoch; + const state = ctx.state; + const { animated, horizontal, isInitialScroll, offset } = params; + const isAnimated = !!animated; +@@ -2650,6 +2678,7 @@ + } + if (!params.isAdjustment) { + state.userScrollContentSize = void 0; ++ state.nativeScrollEpoch = ((_nativeScrollEpoch = state.nativeScrollEpoch) != null ? _nativeScrollEpoch : 0) + 1; + } + scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); + if (isInitialScroll) { +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -497,7 +497,9 @@ + var ENABLE_DEBUG_VIEW = IS_DEV && false; + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -1030,6 +1032,20 @@ + } + + // src/utils/requestAdjust.ts ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ const scrollingTo = state.scrollingTo; ++ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ scrollingTo.offset += positionDiff; ++ if (scrollingTo.targetOffset !== void 0) { ++ scrollingTo.targetOffset += positionDiff; ++ } ++ const watchdog = initialScrollWatchdog.get(state); ++ if (watchdog) { ++ watchdog.targetOffset += positionDiff; ++ } ++} + function requestAdjust(ctx, positionDiff, source) { + var _a3; + const state = ctx.state; +@@ -1049,6 +1065,7 @@ + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state, positionDiff); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); +@@ -1080,8 +1097,11 @@ + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ const nativeScrollEpoch = state.nativeScrollEpoch; + requestAnimationFrame(() => { +- doit(); ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; + if (!state.adjustingFromInitialMount) { + setInitialRenderState(ctx, {}); +@@ -2414,9 +2434,10 @@ + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_d = state.silentInitialScrollReturn) == null ? void 0 : _d.call(state); + cancelScrollCompletionChecks(state); + const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { +@@ -2537,7 +2558,10 @@ + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; ++ var _a3, _b; ++ if (Platform.OS !== "web") { ++ ctx.state.nativeScrollEpoch = ((_b = ctx.state.nativeScrollEpoch) != null ? _b : 0) + 1; ++ } + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { +@@ -2587,10 +2611,13 @@ + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ const returnToTarget = () => { ++ var _a4, _b2; ++ state.silentInitialScrollReturn = void 0; ++ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); ++ }; ++ state.silentInitialScrollReturn = returnToTarget; ++ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +@@ -2619,6 +2646,7 @@ + + // src/core/doScrollTo.native.ts + function doScrollTo(ctx, params) { ++ var _nativeScrollEpoch; + const state = ctx.state; + const { animated, horizontal, isInitialScroll, offset } = params; + const isAnimated = !!animated; +@@ -2629,6 +2657,7 @@ + } + if (!params.isAdjustment) { + state.userScrollContentSize = void 0; ++ state.nativeScrollEpoch = ((_nativeScrollEpoch = state.nativeScrollEpoch) != null ? _nativeScrollEpoch : 0) + 1; + } + scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); + if (isInitialScroll) { diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 5865a4976761..b3446ceaf904 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -44,3 +44,12 @@ - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On Android, open a chat from an LHN row with a Fix action, so it opens at the linked expense. When the Latest messages button appears, the messages on screen should not move. + +### [@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch](@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch) + +- Reason: Before a list is ready, LegendList moves its item positions right away but moves its native scroll anchor a frame later. An initial scroll dispatched in between already lands on the adjusted offset, so the late anchor move shifted the list again, and LegendList's retries then reissued the offset recorded when the scroll started. A retry for scrolls Android reports silently also left the list one point off when the scroll finished first. On Android, a chat that opens at a linked message appeared with the message at the top of the list and then moved down to its offset. This patch skips anchor moves that a later native scroll already includes, moves the pending initial-scroll target with each adjustment, and completes the retry before the scroll finishes. +- Scope: Only the native bundles, and only while an initial scroll is in flight or the list is not ready yet. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/604 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On Android, clear the cache and open a chat from an LHN row with a Fix action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index 8e674b3aeead..3e882ec4cad0 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -333,10 +333,12 @@ function useReportActionsListScroll({ } lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; - if (isNearStart) { - loadOlderChatsOnStartReached(); - } else { + if (!isNearStart) { lastRequestedOldestActionIDRef.current = undefined; + } else if (readyEndListIDRef.current === listID) { + // Before the initial scroll lands, the list still reports offsets near the top. Loading older actions then + // inserts a page above the target while it is being positioned. + loadOlderChatsOnStartReached(); } const bottomRelativeEvent = { diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 9b03bc67e856..32325b9ba09a 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1376,6 +1376,42 @@ describe('ReportActionsList (body)', () => { expect(mockLoadOlderChats).toHaveBeenCalledTimes(2); }); + it('does not load older pages from scroll events before the list is positioned', () => { + // Given a list whose initial scroll has not landed yet, so it still reports an offset near the top. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + }); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + const createScrollEvent = (offset: number) => ({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + + // When a scroll event arrives before LegendList is ready. + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + + // Then no older page is requested, because it would be inserted above the target while the list positions it. + expect(mockLoadOlderChats).not.toHaveBeenCalled(); + + // When the list is ready and the reader is still near the top. + act(() => getCapturedListProps()?.onReady?.()); + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + + // Then the older page loads as usual. + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + }); + describe('System message presentation', () => { const systemActions = ( [ From cb78aff9fb26176c33fd9d156c7f67071aa10c80 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Nov 2026 13:09:23 +0100 Subject: [PATCH 101/109] fix: keep the skeleton below a linked chat until its newer messages render When a chat opens at a linked message that is the newest loaded one, the newer messages load afterwards. LegendList hides each new row until it is measured, and rendering a page of them can take seconds on a slow device. The footer skeleton moved below the hidden rows as soon as they entered the data, so the area below the link stayed blank until they appeared. The linked row now draws its own absolutely positioned skeleton over that area. It hides itself in the frame LegendList measures the first newer row, so the rows replace it in place without a blank frame or a jump. Co-Authored-By: Claude Opus 5.5 --- src/hooks/useReportActionsScroll.ts | 19 +++ src/pages/inbox/ActionListTypes.ts | 11 +- .../ReportActionsListContent.tsx | 13 +- .../SkeletonBelowLinkedAction.tsx | 68 +++++++++++ .../inbox/report/ReportActionsList/types.ts | 3 + .../useReportActionsListRenderItem.tsx | 24 +++- .../useReportActionsListScroll.ts | 6 + src/styles/utils/positioning.ts | 3 + tests/ui/ReportActionsListTest.tsx | 36 ++++++ tests/ui/SkeletonBelowLinkedActionTest.tsx | 115 ++++++++++++++++++ tests/unit/useReportActionsScrollTest.tsx | 23 ++++ 11 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx create mode 100644 tests/ui/SkeletonBelowLinkedActionTest.tsx diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 8f0e5d337b9c..af5979d61701 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -119,6 +119,15 @@ type UseReportActionsScrollResult = { /** Whether to show a skeleton below a linked final action while newer actions are still to load */ shouldShowNewerActionsSkeleton: boolean; + /** Whether the linked action's row shows a skeleton below it until the first newer row is measured */ + shouldShowSkeletonBelowLinkedAction: boolean; + + /** Whether rows follow the linked action whose row shows that skeleton */ + hasRowsBelowLinkedAction: boolean; + + /** Called by that skeleton once LegendList has measured the first newer row */ + hideSkeletonBelowLinkedAction: () => void; + /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; @@ -350,6 +359,13 @@ function useReportActionsScroll({ setNewerActionsSkeletonListID(listID); } const shouldShowNewerActionsSkeleton = hasNewerActions && (isLinkedTargetLastRow || newerActionsSkeletonListID === listID); + // LegendList hides each newer row until it is measured, and rendering a page of them can take seconds on a slow + // device. The footer skeleton moves below them as soon as they enter the data, so the linked action's row covers + // the area below it until the first of them is measured. + const [measuredNewerRowListID, setMeasuredNewerRowListID] = useState(); + const hasRowsBelowLinkedAction = newerActionsSkeletonListID === listID && targetIndex >= 0 && targetIndex < renderedVisibleReportActions.length - 1; + const shouldShowSkeletonBelowLinkedAction = newerActionsSkeletonListID === listID && measuredNewerRowListID !== listID && (hasNewerActions || hasRowsBelowLinkedAction); + const hideSkeletonBelowLinkedAction = () => setMeasuredNewerRowListID(listID); const isLinkedLastAction = isLinkedTargetLastRow && !hasNewerActions; let initialScrollIndex: number | undefined; let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; @@ -445,6 +461,9 @@ function useReportActionsScroll({ initialScrollIndex, initialScrollIndexParams, shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, onLoad, onItemSizeChanged, trackLinkedMessageScroll, diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts index 29887c07c515..d887ec90afe1 100644 --- a/src/pages/inbox/ActionListTypes.ts +++ b/src/pages/inbox/ActionListTypes.ts @@ -26,7 +26,16 @@ type ActionListRef = { getLayout?: (index: number) => unknown; computeVisibleIndices?: () => {startIndex: number; endIndex: number} | undefined; /** Measured item positions supplied by LegendList. Other list implementations can omit this. */ - getState?: () => {positionByKey: (key: string) => number | undefined; scroll?: number; contentLength?: number; scrollLength?: number} | undefined; + getState?: () => + | { + positionByKey: (key: string) => number | undefined; + sizeAtIndex?: (index: number) => number | undefined; + elementAtIndex?: (index: number) => unknown; + scroll?: number; + contentLength?: number; + scrollLength?: number; + } + | undefined; }; /** Ref to the underlying list instance attached via `ref={}`. */ diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index 4c1e576c05b5..f6d7cf15c787 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -164,6 +164,9 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) loadedInitialViewportListID, shouldShowInitialViewportSkeleton, shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, shouldBeAlignedToTop, onShowPreviousMessages, isFloatingMessageCounterVisible, @@ -200,6 +203,9 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) unreadMarkerReportActionID, linkedReportActionID, onShowPreviousMessages, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, }); const latestActionsFooter = ( @@ -219,7 +225,12 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) reportID={reportID} isDraftPendingCompletion={isDraftPendingCompletion} /> - {shouldShowNewerActionsSkeleton && } + {shouldShowNewerActionsSkeleton && ( + // While the linked row shows its own skeleton, this one only keeps the content tall enough for the link to open near the top + + + + )} ); diff --git a/src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx b/src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx new file mode 100644 index 000000000000..1eb1d53d3e0b --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx @@ -0,0 +1,68 @@ +/** + * Covers the area below a linked final action until LegendList has measured the first newer row there. LegendList hides + * each new row until it is measured. This skeleton is absolutely positioned, so the rows render in place underneath it + * and nothing moves when it goes away. + */ +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +type SkeletonBelowLinkedActionProps = { + /** The linked action's index in the LegendList data. The skeleton waits on the row at the next index. */ + linkedActionIndex: number; + + /** Whether rows follow the linked action, so LegendList is about to measure the first of them */ + hasNewerRows: boolean; + + /** Called when LegendList has measured the first newer row, or finds it off screen, so the list can stop rendering this skeleton */ + onNewerRowMeasured: () => void; +}; + +function SkeletonBelowLinkedAction({linkedActionIndex, hasNewerRows, onNewerRowMeasured}: SkeletonBelowLinkedActionProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {getListRef} = useActionListContext(); + const [isNewerRowMeasured, setIsNewerRowMeasured] = useState(false); + + useEffect(() => { + if (!hasNewerRows) { + return; + } + let frame: number; + const checkNewerRowMeasured = () => { + const listState = getListRef()?.current?.getState?.(); + // A row without a container is off screen, so there is nothing left to cover. + if (listState?.sizeAtIndex?.(linkedActionIndex + 1) === undefined && listState?.elementAtIndex?.(linkedActionIndex + 1)) { + frame = requestAnimationFrame(checkNewerRowMeasured); + return; + } + // Local state re-renders only this view, so the skeleton goes away in the frame the row appears. The list + // re-renders afterwards to drop it for good. + setIsNewerRowMeasured(true); + onNewerRowMeasured(); + }; + frame = requestAnimationFrame(checkNewerRowMeasured); + return () => cancelAnimationFrame(frame); + }, [getListRef, hasNewerRows, linkedActionIndex, onNewerRowMeasured]); + + if (isNewerRowMeasured) { + return null; + } + + return ( + + + + ); +} + +export default SkeletonBelowLinkedAction; diff --git a/src/pages/inbox/report/ReportActionsList/types.ts b/src/pages/inbox/report/ReportActionsList/types.ts index 4eb989e18b35..5986ccaaf796 100644 --- a/src/pages/inbox/report/ReportActionsList/types.ts +++ b/src/pages/inbox/report/ReportActionsList/types.ts @@ -62,6 +62,9 @@ type UseReportActionsListRenderItemParams = Pick< isReportArchived: boolean; sortedReportActions: OnyxTypes.ReportAction[]; onShowPreviousMessages: () => void; + shouldShowSkeletonBelowLinkedAction: boolean; + hasRowsBelowLinkedAction: boolean; + hideSkeletonBelowLinkedAction: () => void; }; type UseReportActionsListTelemetryParams = { diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx index a76ab6bdc009..ed64bc890c01 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx @@ -43,6 +43,7 @@ import React from 'react'; import type {UseReportActionsListRenderItemParams} from './types'; import ReportActionsListRow from './ReportActionsListRow'; +import SkeletonBelowLinkedAction from './SkeletonBelowLinkedAction'; /** Builds the report-actions row renderer and the `extraData` values that must re-render rows when they change. */ function useReportActionsListRenderItem({ @@ -66,6 +67,9 @@ function useReportActionsListRenderItem({ unreadMarkerReportActionID, linkedReportActionID, onShowPreviousMessages, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, }: UseReportActionsListRenderItemParams) { const { report, @@ -138,7 +142,7 @@ function useReportActionsListRenderItem({ const collapsedSystemMessageRun = systemMessageRun && !systemMessageRun.isExpanded ? systemMessageRun : undefined; - return ( + const row = ( ); + if (reportAction.reportActionID !== linkedReportActionID) { + return row; + } + // The linked row always renders as a fragment, so removing the skeleton does not remount the row. + return ( + <> + {row} + {shouldShowSkeletonBelowLinkedAction && ( + + )} + + ); }; // Ensure row presentation updates when these values change without changing the action data. @@ -191,6 +211,8 @@ function useReportActionsListRenderItem({ latestConciergeFeedbackActionIDs, expandedSystemMessageReportActionIDs, focusRequestedReportActionID, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, ]; return {renderItem, extraData}; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index 3e882ec4cad0..bf7159aee031 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -195,6 +195,9 @@ function useReportActionsListScroll({ initialScrollIndex, initialScrollIndexParams, shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, onLoad, onItemSizeChanged, trackLinkedMessageScroll, @@ -415,6 +418,9 @@ function useReportActionsListScroll({ loadedInitialViewportListID, shouldShowInitialViewportSkeleton, shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, shouldBeAlignedToTop, onShowPreviousMessages, isFloatingMessageCounterVisible, diff --git a/src/styles/utils/positioning.ts b/src/styles/utils/positioning.ts index dcdc6fedf79a..eb4a182c24f8 100644 --- a/src/styles/utils/positioning.ts +++ b/src/styles/utils/positioning.ts @@ -69,4 +69,7 @@ export default { bFull: { bottom: '100%', }, + tFull: { + top: '100%', + }, } satisfies Record; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 2b07dc6d424f..0fd4c52be10e 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1030,6 +1030,42 @@ describe('ReportActionsList (body)', () => { expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); }); + it('renders the skeleton below a linked action inside its row while newer rows are measured', () => { + // Given a chat opened at a linked action while LegendList still hides the newer rows below it. + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll(createMock[0]>({})); + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, shouldShowSkeletonBelowLinkedAction: true, hideSkeletonBelowLinkedAction: jest.fn()}); + const renderCell = (item: OnyxTypes.ReportAction, index: number) => { + const cell = getCapturedListProps()?.renderItem?.({item, index}); + if (!React.isValidElement(cell)) { + throw new Error('Expected renderItem to return an element'); + } + render(cell); + }; + try { + renderReportActionsList(); + const [createdAction, linkedAction] = mockReportActions; + if (!createdAction || !linkedAction) { + throw new Error('Expected two report actions'); + } + + // When LegendList renders the linked row. + renderCell(linkedAction, 1); + + // Then the row carries the skeleton, so newer rows added after it cannot push the skeleton away. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + // When LegendList renders the row above it. + renderCell(createdAction, 0); + + // Then that row has no skeleton, because only the linked row borders the area where the newer rows appear. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + } + }); + it.each(['2', 'another-action'])('positions a newly opened link after the previous link was cleared: %s', async (reportActionID) => { // Given a positioned linked list is kept mounted when returning to its latest messages. mockUseNetwork.mockReturnValue({isOffline: false}); diff --git a/tests/ui/SkeletonBelowLinkedActionTest.tsx b/tests/ui/SkeletonBelowLinkedActionTest.tsx new file mode 100644 index 000000000000..50870fc6c122 --- /dev/null +++ b/tests/ui/SkeletonBelowLinkedActionTest.tsx @@ -0,0 +1,115 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import {ActionListContext} from '@pages/inbox/ActionListContext'; +import SkeletonBelowLinkedAction from '@pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction'; + +import React from 'react'; + +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); + +const LINKED_ACTION_INDEX = 4; +const pendingFrames: FrameRequestCallback[] = []; +const renderedRowIndices = new Set(); +const measuredRowSizes = new Map(); + +// Queue animation frames so each measurement check runs only when the test flushes it. +jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => pendingFrames.push(callback)); +jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {}); + +function flushFrames() { + act(() => { + for (const callback of pendingFrames.splice(0)) { + callback(0); + } + }); +} + +// Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. +function buildActionListContextValue() { + return { + scrollOffsetRef: {current: 0}, + getScrollOffset: () => 0, + registerListRef: () => {}, + getListRef: () => ({ + current: { + scrollToIndex: jest.fn(), + scrollToEnd: jest.fn(), + scrollToOffset: jest.fn(), + getState: () => ({ + positionByKey: () => undefined, + sizeAtIndex: (index: number) => measuredRowSizes.get(index), + elementAtIndex: (index: number) => (renderedRowIndices.has(index) ? {} : undefined), + }), + }, + }), + }; +} + +function renderSkeleton(hasNewerRows: boolean, onNewerRowMeasured: () => void) { + return render( + + + , + ); +} + +describe('SkeletonBelowLinkedAction', () => { + beforeEach(() => { + pendingFrames.length = 0; + renderedRowIndices.clear(); + measuredRowSizes.clear(); + }); + + it('stays until LegendList measures the first row below the linked action', () => { + // Given a linked action whose newer page has not loaded yet. + const onNewerRowMeasured = jest.fn(); + const view = renderSkeleton(false, onNewerRowMeasured); + flushFrames(); + + // Then the skeleton covers the area, and nothing is checked before rows exist below the link. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(onNewerRowMeasured).not.toHaveBeenCalled(); + + // When the newer page adds a row that LegendList has rendered but not measured, so it is still hidden. + renderedRowIndices.add(LINKED_ACTION_INDEX + 1); + view.rerender( + + + , + ); + flushFrames(); + + // Then the skeleton stays, because removing it would leave the area blank until the row is revealed. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(onNewerRowMeasured).not.toHaveBeenCalled(); + + // When LegendList measures the row, which also reveals it. + measuredRowSizes.set(LINKED_ACTION_INDEX + 1, 80); + flushFrames(); + + // Then the skeleton hides at once, because the revealed row now fills the area, and it calls back so the list stops rendering it. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(onNewerRowMeasured).toHaveBeenCalledTimes(1); + }); + + it('goes away when the first newer row is off screen', () => { + // Given newer rows below the link, but the first one has no container because the reader scrolled far away. + const onNewerRowMeasured = jest.fn(); + renderSkeleton(true, onNewerRowMeasured); + + // When the check runs. + flushFrames(); + + // Then the skeleton goes away, because LegendList will not render that row until it comes back into range. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(onNewerRowMeasured).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 0b41113bf2cc..1de68c49f528 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -344,6 +344,29 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldShowNewerActionsSkeleton).toBe(false); }); + it('keeps the skeleton below a linked action until its row reports the first newer row as measured', async () => { + // Given a linked final action that opened above a skeleton for the newer page. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result, rerender} = await renderScroll({hasNewerActions: true, sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); + expect(result.current.shouldShowSkeletonBelowLinkedAction).toBe(true); + + // When the newest page adds a row below the link. + const newerAction = makeAction('2'); + rerender(buildParams({sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + + // Then the linked row keeps its skeleton, because LegendList hides the new row until it is measured. + // The footer skeleton is gone, since nothing newer is left to load. + expect(result.current.shouldShowSkeletonBelowLinkedAction).toBe(true); + expect(result.current.shouldShowNewerActionsSkeleton).toBe(false); + + // When the skeleton reports that LegendList measured the row. + act(() => result.current.hideSkeletonBelowLinkedAction()); + + // Then the linked row drops its skeleton, because the measured row is visible and fills the space below the link. + expect(result.current.shouldShowSkeletonBelowLinkedAction).toBe(false); + }); + it('prefers a linked action over a later unread marker', async () => { // Given a linked action followed by an unread action in chronological order. mockRouteParams = {reportActionID: LINKED_ACTION_ID}; From e0fcfcbf03b56c6639760d40653c395def26213a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 10 Nov 2026 16:16:53 +0100 Subject: [PATCH 102/109] fix: preserve chat readiness and offline scroll position Keep cached parent actions linked offline and focus restored drafts only after the message list is ready. Preserve the loaded list and its native header anchor on reconnect. Let offline Latest messages jumps complete using the newest cached page. Co-authored-by: Codex --- ....2+007+preserve-native-header-anchor.patch | 64 +++++++++++++++++++ .../MoneyRequestReportActionsList.tsx | 9 ++- src/hooks/useLinkedReportActionScroll.ts | 9 +-- src/hooks/useReportActionsListModel.ts | 6 +- src/libs/ReportUtils.ts | 3 +- src/pages/inbox/ActionListContext.tsx | 32 ++++++++-- src/pages/inbox/ActionListTypes.ts | 1 + src/pages/inbox/ChatReportActions.tsx | 4 +- .../inbox/report/AncestorReportActionItem.tsx | 3 - .../inbox/report/AskConciergeWelcome.tsx | 11 +++- .../ComposerWithSuggestions.tsx | 6 +- .../useShouldSkipComposerAutoFocus.ts | 5 +- .../ReportActionsListContent.tsx | 9 ++- .../useReportActionsListScroll.ts | 9 ++- .../inbox/report/ReportActionsListContext.tsx | 2 +- .../useReportActionsNewActionLiveTail.ts | 5 +- tests/ui/ComposerImperativeRefTest.tsx | 32 +++++++++- tests/ui/ReportActionsListTest.tsx | 40 +++++++++++- tests/unit/ActionListContextProviderTest.tsx | 29 ++++++++- tests/unit/ReportUtilsTest.ts | 31 +++++++++ .../useShouldSkipComposerAutoFocus.test.tsx | 13 ++++ .../useReportActionsNewActionLiveTailTest.ts | 30 ++++++++- tests/unit/useReportActionsScrollTest.tsx | 20 +++++- 23 files changed, 335 insertions(+), 38 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch new file mode 100644 index 000000000000..e23ee197cf61 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch @@ -0,0 +1,64 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 134135f..ef5db5d 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -6705,7 +6705,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + const sizeDiff = nextHeaderSize - previousHeaderSize; + const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; + const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; +- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; ++ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { + const { state } = ctx; +@@ -6716,7 +6716,17 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ let positionDiff = size - previousHeaderSize; ++ if (Platform.OS === "android") { ++ const maxScroll = getScrollRangeEnd(ctx); ++ if (state.scroll > maxScroll + SCROLL_ADJUST_EPSILON) { ++ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ positionDiff = targetScroll - maxScroll; ++ } ++ } ++ requestAdjust(ctx, positionDiff); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index cb06128..4c6d3d6 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -6684,7 +6684,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + const sizeDiff = nextHeaderSize - previousHeaderSize; + const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; + const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; +- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; ++ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { + const { state } = ctx; +@@ -6695,7 +6695,17 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ let positionDiff = size - previousHeaderSize; ++ if (Platform.OS === "android") { ++ const maxScroll = getScrollRangeEnd(ctx); ++ if (state.scroll > maxScroll + SCROLL_ADJUST_EPSILON) { ++ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ positionDiff = targetScroll - maxScroll; ++ } ++ } ++ requestAdjust(ctx, positionDiff); + } + } + state.didMeasureHeader = true; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 649231198549..082e3818cc8a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -32,7 +32,7 @@ import { import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; -import {useActionListRef} from '@pages/inbox/ActionListContext'; +import {useActionListReady, useActionListRef} from '@pages/inbox/ActionListContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; @@ -98,6 +98,7 @@ function reportNameValuePairsSelector(reportNameValuePairs: OnyxEntry { + setIsReady(true); syncBottomOffsetFromLayout(event); recordTimeToMeasureItemLayout(); }; @@ -419,7 +421,10 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon { + setIsReady(true); + onLayout?.(event); + }} /> )} {!isReportEmpty && !!reportStable && ( diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index ba8d5302fe86..2fbca090ab4a 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -1,3 +1,4 @@ +/** Keeps a linked action positioned as its measured layout settles, until the reader takes over scrolling. */ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {useActionListContext} from '@pages/inbox/ActionListContext'; @@ -19,7 +20,6 @@ type LinkedReportActionScrollParams = { isLastAction: boolean; }; -/** Keeps a linked action positioned as its measured layout settles, until the reader takes over scrolling. */ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, isLastAction}: LinkedReportActionScrollParams) { const isFocused = useIsFocused(); const {getListRef} = useActionListContext(); @@ -67,7 +67,7 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, const state = getListRef()?.current?.getState?.(); const position = state?.positionByKey(linkedReportActionID); if (position !== undefined && state?.scroll !== undefined && state.contentLength !== undefined && state.scrollLength !== undefined) { - const targetOffset = Math.max(0, Math.min(position - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, state.contentLength - state.scrollLength)); + const targetOffset = Math.max(0, Math.min(position + (state.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, state.contentLength - state.scrollLength)); // LegendList includes pending native anchoring in its scroll offset. An imperative // scroll to that same offset would apply the height correction twice on Android. if (Math.abs(targetOffset - state.scroll) <= 1) { @@ -137,12 +137,13 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, if (!shouldPositionRef.current || readyListID !== listID || !linkedReportActionID || isLastAction) { return; } - const position = getListRef()?.current?.getState?.()?.positionByKey(linkedReportActionID); + const state = getListRef()?.current?.getState?.(); + const position = state?.positionByKey(linkedReportActionID); if (position === undefined) { return; } const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; - const targetOffset = Math.max(0, Math.min(position - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, contentSize.height - layoutMeasurement.height)); + const targetOffset = Math.max(0, Math.min(position + (state?.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, contentSize.height - layoutMeasurement.height)); // Native anchoring can commit after the row-size correction. Check the resulting viewport // as well, so that late adjustment cannot move a successfully positioned link out of view. if (Math.abs(targetOffset - contentOffset.y) > 1) { diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 76a0028a674a..546eaf1282f6 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -58,9 +58,9 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, { selector: reportActionsListLoadingStateSelector, }); - // This flag is monotonic for one report visit. Keep it true if the RAM-only entry is briefly - // absent while optimistic writes are merged, so the hydrated list never returns to its loading state. - const hasOnceLoadedReportActions = useInitial(reportLoadingState?.hasOnceLoadedReportActions ? true : undefined); + // Cached actions shown offline already have a usable layout. Refresh them in place on reconnect, + // and keep this visit loaded if its RAM-only entry briefly disappears during optimistic updates. + const hasOnceLoadedReportActions = useInitial(reportLoadingState?.hasOnceLoadedReportActions || (isOffline && reportActions.length > 0) ? true : undefined); const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; const isInitialReportLoadPending = !hasOnceLoadedReportActions && (isReportLoadPending || isLoadingInitialReportActions !== false); const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 378ccf667154..bd52d98b1a34 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12302,7 +12302,6 @@ function navigateToLinkedReportAction( ancestor: Ancestor, isInNarrowPaneModal: boolean, canUserPerformWrite: boolean | undefined, - isOffline: boolean, visibleReportActionsData?: VisibleReportActionsDerivedValue, ) { if (isInNarrowPaneModal) { @@ -12335,7 +12334,7 @@ function navigateToLinkedReportAction( const reportID = ancestor.report.reportID; const isVisibleAction = !!reportID && isReportActionVisible(ancestor.reportAction, reportID, canUserPerformWrite, visibleReportActionsData); - if (isVisibleAction && !isOffline) { + if (isVisibleAction) { // Pop the chat report screen before navigating to the linked report action. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID, ancestor.reportAction.reportActionID)); } diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index f620b9e53724..749b77145008 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,6 +1,6 @@ import type {ReactNode, RefObject} from 'react'; -import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; +import React, {createContext, useContext, useLayoutEffect, useRef, useState} from 'react'; import type ActionListRefType from './ActionListTypes'; import type {ActionListRef} from './ActionListTypes'; @@ -25,6 +25,14 @@ const ActionListContext = createContext({ getListRef: () => null, }); +const ActionListReadyContext = createContext(true); +const ActionListReadyActionsContext = createContext<(isReady: boolean) => void>(() => {}); + +/** Coordinates automatic composer focus with the list's visible initial layout. */ +function useActionListReady() { + return {isReady: useContext(ActionListReadyContext), setIsReady: useContext(ActionListReadyActionsContext)}; +} + function useActionListContext() { return useContext(ActionListContext); } @@ -35,18 +43,24 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); + const {setIsReady} = useActionListReady(); const listRef = useRef(null); useLayoutEffect(() => { + setIsReady(false); registerListRef(listRef); - return () => registerListRef(null); - }, [registerListRef]); + return () => { + registerListRef(null); + setIsReady(false); + }; + }, [registerListRef, setIsReady]); return listRef; } /** Owns the action-list context value so screens don't wire it up themselves. */ function ActionListContextProvider({children}: {children: ReactNode}) { + const [isReady, setIsReady] = useState(false); // Each list owns its own ref locally and publishes it here on mount; only the register/get // callbacks live in context, so attaching `ref={}` stays local to each list. const listRefHolder = useRef(null); @@ -64,8 +78,14 @@ function ActionListContextProvider({children}: {children: ReactNode}) { // The value is only stable refs and ref accessors — none of it triggers re-renders — so a // single context is intentional; splitting state/actions into two providers would add ceremony // for no benefit. - // eslint-disable-next-line rulesdir/context-provider-split-values - return {children}; + return ( + + + {/* eslint-disable-next-line rulesdir/context-provider-split-values */} + {children} + + + ); } -export {ActionListContext, ActionListContextProvider, useActionListContext, useActionListRef}; +export {ActionListContext, ActionListContextProvider, useActionListContext, useActionListReady, useActionListRef}; diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts index d887ec90afe1..c2a8367c16ac 100644 --- a/src/pages/inbox/ActionListTypes.ts +++ b/src/pages/inbox/ActionListTypes.ts @@ -34,6 +34,7 @@ type ActionListRef = { scroll?: number; contentLength?: number; scrollLength?: number; + headerSize?: number; } | undefined; }; diff --git a/src/pages/inbox/ChatReportActions.tsx b/src/pages/inbox/ChatReportActions.tsx index 2c98a0405ff4..5797308d81e1 100644 --- a/src/pages/inbox/ChatReportActions.tsx +++ b/src/pages/inbox/ChatReportActions.tsx @@ -1,5 +1,6 @@ /** Keep chat effects unmounted during app loading, except for the Concierge welcome screen. */ import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; +import useInitial from '@hooks/useInitial'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -24,8 +25,9 @@ function ChatReportActions({report}: ChatReportActionsProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isAppLoadPending = useIsAppLoadPending(); const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); + const hasRenderedReportActions = useInitial(isOffline || !isAppLoadPending || isConciergeMainDM ? true : undefined); - if (isAppLoadPending && !isOffline && !isConciergeMainDM) { + if (!hasRenderedReportActions) { return ( { @@ -128,7 +126,6 @@ function AncestorReportActionItem({ }, isInNarrowPaneModal, canUserPerformWriteActionReportUtils(report, isArchived), - isOffline, ); }; diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index ef7d0d2fca3b..9ca26e1c9e55 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -6,9 +6,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; +import {useActionListReady} from '@pages/inbox/ActionListContext'; + import ONYXKEYS from '@src/ONYXKEYS'; -import React from 'react'; +import React, {useEffect} from 'react'; import AskConciergeEmptyState from './AskConciergeEmptyState'; import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; @@ -22,6 +24,8 @@ type AskConciergeWelcomeProps = { }; function AskConciergeWelcome({reportID, onShowPreviousMessages}: AskConciergeWelcomeProps) { + const {setIsReady} = useActionListReady(); + useEffect(() => () => setIsReady(false), [setIsReady]); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -31,7 +35,10 @@ function AskConciergeWelcome({reportID, onShowPreviousMessages}: AskConciergeWel style={[!shouldUseNarrowLayout && styles.flexGrow0, styles.conciergeAskColumn]} contentContainerStyle={[styles.flexGrow1, styles.justifyContentCenter]} keyboardShouldPersistTaps="handled" - onLayout={() => markOpenReportEnd(reportID, report, {warm: true})} + onLayout={() => { + setIsReady(true); + markOpenReportEnd(reportID, report, {warm: true}); + }} > { const isModalVisible = modal?.isVisible; @@ -858,7 +859,9 @@ function ComposerWithSuggestions({ // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused))) { + if ( + !((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused || prevShouldSkipAutoFocus)) + ) { return; } @@ -882,6 +885,7 @@ function ComposerWithSuggestions({ isNextModalWillOpenRef, shouldAutoFocus, shouldSkipAutoFocus, + prevShouldSkipAutoFocus, isSidePanelHiddenOrLargeScreen, shouldHideSidePanel, isInSidePanel, diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts b/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts index d4029d0ff96e..2952f08bba4e 100644 --- a/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts @@ -3,6 +3,8 @@ import useOnyx from '@hooks/useOnyx'; import getPlatform from '@libs/getPlatform'; import {isOneTransactionReport, isReportTransactionThread} from '@libs/ReportUtils'; +import {useActionListReady} from '@pages/inbox/ActionListContext'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -24,12 +26,13 @@ const hasExpenseCarouselSelector = (transactionIDs: string[] | undefined) => (tr * A report with a single expense is shown as that expense's view. */ function useShouldSkipComposerAutoFocus(report: OnyxEntry): boolean { + const {isReady} = useActionListReady(); // Read off the context because `useRoute` throws outside a screen. const routeName = useContext(NavigationRouteContext)?.name; const [hasExpenseCarousel = false] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, {selector: hasExpenseCarouselSelector}); const isExpenseView = isReportTransactionThread(report) || isOneTransactionReport(report); - return isWeb && routeName !== SCREENS.REPORT && hasExpenseCarousel && isExpenseView; + return !isReady || (isWeb && routeName !== SCREENS.REPORT && hasExpenseCarousel && isExpenseView); } export default useShouldSkipComposerAutoFocus; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index f6d7cf15c787..8ce028b09fe6 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -22,7 +22,7 @@ import {isArchivedReport} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; -import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useActionListContext, useActionListReady} from '@pages/inbox/ActionListContext'; import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import AskConciergeWelcome from '@pages/inbox/report/AskConciergeWelcome'; import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; @@ -39,7 +39,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {Platform, View} from 'react-native'; import type {ReportActionsListProps} from './types'; @@ -193,6 +193,11 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) useReportActionsListTelemetry({reportID, listID, loadedInitialViewportListID, shouldShowInitialViewportSkeleton}); + const {setIsReady} = useActionListReady(); + useEffect(() => { + setIsReady(shouldShowConciergeWelcome || (!shouldShowInitialViewportSkeleton && loadedInitialViewportListID === listID)); + }, [listID, loadedInitialViewportListID, setIsReady, shouldShowConciergeWelcome, shouldShowInitialViewportSkeleton]); + const {renderItem, extraData} = useReportActionsListRenderItem({ ...reportActionsListData, reportID, diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index bf7159aee031..7aed6dd7c06b 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -89,12 +89,16 @@ function useReportActionsListScroll({ const {getScrollOffset, scrollOffsetRef} = useActionListContext(); const listRef = useActionListRef(); const legendListRef = useRef(null); + const headerSizeRef = useRef(0); useImperativeHandle( listRef, (): ActionListRef => ({ getScrollableNode: (): unknown => legendListRef.current?.getScrollableNode(), - getState: () => legendListRef.current?.getState(), + getState: () => { + const state = legendListRef.current?.getState(); + return state ? {...state, headerSize: headerSizeRef.current} : undefined; + }, computeVisibleIndices: () => { const state = legendListRef.current?.getState(); return state ? {startIndex: state.start, endIndex: state.end} : undefined; @@ -445,6 +449,9 @@ function useReportActionsListScroll({ onReady: handleListReady, onItemSizeChanged, onContentSizeChange: trackContentSizeChange, + onMetricsChange: ({headerSize}: {headerSize: number}) => { + headerSizeRef.current = headerSize; + }, }, }; } diff --git a/src/pages/inbox/report/ReportActionsListContext.tsx b/src/pages/inbox/report/ReportActionsListContext.tsx index 447e816bb66d..3779b1479cc7 100644 --- a/src/pages/inbox/report/ReportActionsListContext.tsx +++ b/src/pages/inbox/report/ReportActionsListContext.tsx @@ -91,7 +91,7 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin const shouldShowSkeletonForInitialLoad = isReportLoadPending && (isReportDataIncomplete || isMissingReportActions) && !isOffline; - const shouldShowSkeletonForAppLoad = !!isLoadingApp && !isOffline; + const shouldShowSkeletonForAppLoad = !!isLoadingApp && !hasOnceLoadedReportActions && !isOffline; // Show skeleton for the Concierge side panel until report data has been loaded // at least once. Before the first openReport response, hasOlderActions is diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index b2d7e8767edf..a7fb9fdbf9f9 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -257,7 +257,8 @@ function useReportActionsNewActionLiveTail({ if (liveTailJumpRef.current.stage !== 'await_scroll') { return; } - if (!hasNewestReportAction) { + // Offline jumps can only reach the newest cached page, which may not contain the newest server message. + if (!treatAsNoPaginationAnchor || (!isOffline && !hasNewestReportAction)) { return; } @@ -267,7 +268,7 @@ function useReportActionsNewActionLiveTail({ queueMicrotask(() => { setShouldScrollToBottom(true); }); - }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible, setShouldScrollToBottom]); + }, [hasNewestReportAction, isOffline, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible, setShouldScrollToBottom]); useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? diff --git a/tests/ui/ComposerImperativeRefTest.tsx b/tests/ui/ComposerImperativeRefTest.tsx index aea955d2df89..add28bc779b6 100644 --- a/tests/ui/ComposerImperativeRefTest.tsx +++ b/tests/ui/ComposerImperativeRefTest.tsx @@ -49,6 +49,7 @@ import createMock from '../utils/createMock'; let mockShouldMountComposer = true; let mockShouldUseNativeComposer = false; +let mockComposerText = ''; let mockMarkdownInput: TextInput | (HTMLDivElement & Pick) | null = null; const WebComposer = jest.requireActual('@components/Composer/implementation/index.tsx').default; const {forceClearInput: clearWebInput} = jest.requireActual('@libs/ComponentUtils/index.ts'); @@ -141,7 +142,7 @@ jest.mock('@pages/inbox/report/useDebouncedSaveReportActionDraft', () => () => ( jest.mock('@pages/inbox/report/useDraftMessageVideoAttributeCache', () => () => {}); jest.mock('@pages/inbox/report/useShouldEditInComposer', () => () => false); jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => ({ - useComposerText: () => '', + useComposerText: () => mockComposerText, useComposerActions: () => ({ setText: jest.fn< ReturnType['setText']>, @@ -248,6 +249,7 @@ describe('Composer imperative refs', () => { jest.clearAllMocks(); mockShouldMountComposer = true; mockShouldUseNativeComposer = false; + mockComposerText = ''; jest.mocked(useIsInLandscapeMode).mockReturnValue(false); jest.mocked(useAnimatedRef).mockImplementation(jest.requireActual('react-native-reanimated/src/hook/useAnimatedRef.ts').useAnimatedRef); jest.mocked(isMobileChrome).mockReturnValue(false); @@ -424,6 +426,34 @@ describe('Composer imperative refs', () => { expect(objectRef.current).toBeNull(); }); + it('focuses the existing composer when its initial loading gate opens', async () => { + // Given the input mounted under the report's loading skeleton, so its mount-time autofocus was skipped. + mockShouldUseNativeComposer = true; + mockComposerText = 'Restored draft'; + const inputFocus = jest.fn, Parameters>(); + mockMarkdownInput = createMock({focus: inputFocus, isFocused: () => false}); + const props = forwardingProps(React.createRef()); + const view = render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + expect(inputFocus).not.toHaveBeenCalled(); + + // When the positioned messages appear, the existing input must focus without remounting or another navigation. + view.rerender( + , + ); + + // Then a restored draft can be edited as soon as the report is visible. + await waitFor(() => expect(inputFocus).toHaveBeenCalledTimes(1)); + }); + it('keeps the outer commands tied to the current input and safely handles an absent input', () => { // Given the outer handle exposes only the commands its consumers use. mockShouldUseNativeComposer = true; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 0fd4c52be10e..9cf59767bf49 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -30,6 +30,7 @@ import Visibility from '@libs/Visibility'; import {ActionListContext} from '@pages/inbox/ActionListContext'; import type ActionListRefType from '@pages/inbox/ActionListTypes'; +import ChatReportActions from '@pages/inbox/ChatReportActions'; import {useConciergeChatHistoryActions, useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; @@ -246,7 +247,7 @@ type MockLegendListProps = { contentContainerStyle?: StyleProp; data?: OnyxTypes.ReportAction[]; drawDistance?: number; - // eslint-disable-next-line @typescript-eslint/naming-convention + // eslint-disable-next-line @typescript-eslint/naming-convention -- The mock must preserve LegendList's public prop name. experimental_hideItemsUntilMeasured?: boolean; extraData?: unknown; getItemType?: (item: OnyxTypes.ReportAction) => string; @@ -1006,6 +1007,43 @@ describe('ReportActionsList (body)', () => { expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); }); + it('preserves the visible offline list while reconnecting and loading its first server page', async () => { + // Given cached messages have already been positioned while their OpenReport cannot finish offline. + mockUseNetwork.mockReturnValue({isOffline: true}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + let isAppLoadPending = true; + mockUseOnyx.mockImplementation((key, options) => { + if (key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { + return [isAppLoadPending, {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); + const view = render(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + + // When connectivity returns, both the queued request and its eventual response must preserve that viewport. + mockShouldCallLegendListOnLoad = false; + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsReportLoadPending.mockReturnValue(true); + view.rerender(); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = false; + mockUseIsReportLoadPending.mockReturnValue(false); + isAppLoadPending = false; + view.rerender(); + await waitForBatchedUpdatesWithAct(); + + // Then no replacement list resets the reader's scroll position or flashes another loading cover. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + it('keeps the positioned list mounted when sending a message clears the linked action', async () => { // Given a linked message has finished its initial positioning. mockUseNetwork.mockReturnValue({isOffline: false}); diff --git a/tests/unit/ActionListContextProviderTest.tsx b/tests/unit/ActionListContextProviderTest.tsx index d255306e5cdc..ad60c4902f6b 100644 --- a/tests/unit/ActionListContextProviderTest.tsx +++ b/tests/unit/ActionListContextProviderTest.tsx @@ -1,14 +1,41 @@ import {act, renderHook} from '@testing-library/react-native'; -import {ActionListContextProvider, useActionListContext} from '@pages/inbox/ActionListContext'; +import {ActionListContextProvider, useActionListContext, useActionListReady, useActionListRef} from '@pages/inbox/ActionListContext'; import type ActionListRefType from '@pages/inbox/ActionListTypes'; +function TestList() { + useActionListRef(); + return null; +} + /** * `ActionListContextProvider` owns a private holder and exposes `registerListRef(ref)` / `getListRef()` * plus the scroll refs so each list owns its ref locally while handlers resolve it at call time. * The tests exercise the public surface (the provider + the `useActionListContext` consumer hook). */ describe('ActionListContextProvider', () => { + it('resets readiness when the positioned list is replaced', () => { + // Given a mounted report whose list has finished its initial positioning. + let listKey = 'first'; + const {result, rerender} = renderHook(() => useActionListReady(), { + wrapper: ({children}) => ( + + {children} + + + ), + }); + act(() => result.current.setIsReady(true)); + expect(result.current.isReady).toBe(true); + + // When another list registers with the same provider, it needs its own initial layout. + listKey = 'second'; + rerender({}); + + // Then the new list starts covered and publishes its ref before any scroll handler runs. + expect(result.current.isReady).toBe(false); + }); + it('getListRef() returns null before any ref is registered', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 420d73da6f31..4a0ec3e96ff9 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -173,6 +173,7 @@ import { getWhisperDisplayNames, getWorkspaceIcon, getWorkspaceNameUpdatedMessage, + navigateToLinkedReportAction, hasActionWithErrorsForTransaction, hasEmptyReportsForPolicy, hasExpensifyGuidesEmails, @@ -365,6 +366,7 @@ jest.mock('@libs/Log', () => ({ })); jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), setNavigationActionToMicrotaskQueue: jest.fn(), navigate: jest.fn(), getActiveRoute: jest.fn(() => 'mock-route'), @@ -623,6 +625,35 @@ describe('ReportUtils', () => { }); beforeEach(() => IntlStore.load(CONST.LOCALES.DEFAULT).then(waitForBatchedUpdates)); + describe('navigateToLinkedReportAction', () => { + it('returns to the linked parent action using cached visibility', () => { + // Given the thread's parent action is visible in the cached report, including while offline. + const parentReport = {...createRandomReport(1), reportID: '1234'}; + const parentAction = {...createRandomReportAction(1), reportActionID: '5678', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}; + jest.mocked(Navigation.goBack).mockClear(); + + // When the reader taps the parent message, navigation must carry its link back to the parent. + navigateToLinkedReportAction({report: parentReport, reportAction: parentAction, shouldDisplayNewMarker: false}, false, true, {'1234': {'5678': true}}); + + // Then the parent can position and highlight the message instead of restoring an unrelated viewport. + expect(Navigation.goBack).toHaveBeenLastCalledWith(ROUTES.REPORT_WITH_ID.getRoute('1234', '5678')); + }); + + it('does not link a parent action hidden from the reader', () => { + // Given a cached parent action that the visibility rules exclude. + const parentReport = {...createRandomReport(1), reportID: '1234'}; + const parentAction = {...createRandomReportAction(1), reportActionID: '5678', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}; + jest.mocked(Navigation.goBack).mockClear(); + + // When the reader returns from its thread. + navigateToLinkedReportAction({report: parentReport, reportAction: parentAction, shouldDisplayNewMarker: false}, false, true, {'1234': {'5678': false}}); + + // Then the ordinary report remains reachable without targeting a message it cannot display. + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('1234')); + }); + }); + describe('getIOUReportActionDisplayMessage', () => { const iouReportID = '1234567890'; const policyID = 332; diff --git a/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx b/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx index 4cdbca8c30e9..2f4d1944f74e 100644 --- a/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx +++ b/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx @@ -1,5 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; +import {ActionListContextProvider, useActionListReady} from '@pages/inbox/ActionListContext'; import useShouldSkipComposerAutoFocus from '@pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus'; import CONST from '@src/CONST'; @@ -76,4 +77,16 @@ describe('useShouldSkipComposerAutoFocus', () => { // Then the composer auto-focuses as usual expect(result.current).toBe(false); }); + + it('waits for the message viewport to appear before allowing automatic focus', () => { + // Given a report whose composer mounts while its messages are still covered by the skeleton. + const {result} = renderHook(() => ({skipAutoFocus: useShouldSkipComposerAutoFocus(chatReport), readiness: useActionListReady()}), {wrapper: ActionListContextProvider}); + expect(result.current.skipAutoFocus).toBe(true); + + // When the list reports its positioned viewport is visible. + act(() => result.current.readiness.setIsReady(true)); + + // Then automatic focus can resume, including for a draft restored before the list was ready. + expect(result.current.skipAutoFocus).toBe(false); + }); }); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 5e0f875c1dda..e0ce58270b07 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -125,9 +125,17 @@ describe('useReportActionsNewActionLiveTail', () => { }); it.each([false, true])('reaches the cached newest window offline when in side panel is %s', async (isInSidePanel) => { - // Given an offline report showing an older linked page. + // Given an offline report whose newest cached page does not contain the newest server message. mockIsInSidePanel = isInSidePanel; - const params = buildParams({isOffline: true, linkedReportActionID: '2'}); + const linkedAction = getFakeReportAction(2); + const newestCachedAction = getFakeReportAction(3); + const params = buildParams({ + isOffline: true, + linkedReportActionID: linkedAction.reportActionID, + renderedVisibleReportActions: [linkedAction], + sortedAllReportActionsForPagination: [newestCachedAction, linkedAction], + reportActionPages: [[newestCachedAction.reportActionID], [linkedAction.reportActionID]], + }); const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); // When Latest messages is pressed, release the anchor locally instead of waiting for OpenReport. @@ -139,7 +147,23 @@ describe('useReportActionsNewActionLiveTail', () => { expect(result.current.isScrollToBottomEnabled).toBe(false); // Then request the scroll after the cached newest window enters the list. - rerender({...params, linkedReportActionID: '', treatAsNoPaginationAnchor: true, hasNewestReportAction: true}); + const newestCachedParams = {...params, linkedReportActionID: '', treatAsNoPaginationAnchor: true, renderedVisibleReportActions: [newestCachedAction]}; + rerender(newestCachedParams); + await waitFor(() => expect(result.current.isScrollToBottomEnabled).toBe(true)); + act(() => { + result.current.setIsScrollToBottomEnabled(false); + result.current.completeLiveTailPruneAfterScrollToBottom(); + }); + expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(false); + + // When the reader scrolls away and presses Latest messages again, the completed jump must not block it. + rerender({...newestCachedParams, treatAsNoPaginationAnchor: false}); + act(() => result.current.requestLiveTailJump()); + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(true); + rerender(newestCachedParams); + + // Then another local scroll is requested without ever needing the uncached server message. await waitFor(() => expect(result.current.isScrollToBottomEnabled).toBe(true)); }); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 1de68c49f528..a9a10560373d 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -38,6 +38,7 @@ const mockScrollToBottom = jest.fn(); const mockScrollToIndex = jest.fn(); let mockLinkedActionPosition = 40; let mockListScroll: number | undefined; +let mockListHeaderSize = 0; jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ @@ -218,7 +219,7 @@ function buildActionListContextValue() { scrollToIndex: mockScrollToIndex, scrollToEnd: mockScrollToBottom, scrollToOffset: jest.fn(), - getState: () => ({positionByKey: () => mockLinkedActionPosition, scroll: mockListScroll, contentLength: 5000, scrollLength: 600}), + getState: () => ({positionByKey: () => mockLinkedActionPosition, scroll: mockListScroll, contentLength: 5000, scrollLength: 600, headerSize: mockListHeaderSize}), }, }), }; @@ -255,6 +256,7 @@ describe('useReportActionsScroll', () => { mockRouteParams = {}; mockLinkedActionPosition = 40; mockListScroll = undefined; + mockListHeaderSize = 0; mockIsFocused = true; mockReportRHPActiveRoute = undefined; mockIsFloatingMessageCounterVisible = false; @@ -496,6 +498,22 @@ describe('useReportActionsScroll', () => { }); describe('linked message positioning', () => { + it('does not reposition an Android link already aligned below the offline history header', async () => { + // Given the row position excludes an 800-point header that the scroll offset includes. + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockLinkedActionPosition = 400; + mockListHeaderSize = 800; + mockListScroll = 1160; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]}); + + // When the positioned list becomes ready, it must not start a redundant native scroll. + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + platform.restore(); + }); + it('lets Android preserve an already aligned linked message when a preceding row changes height', async () => { // Given Android has already positioned the link and is preserving it during measurement. const platform = jest.replaceProperty(Platform, 'OS', 'android'); From f0240c83cf221055db152961fae4d642cb5b5557 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Nov 2026 12:03:31 +0100 Subject: [PATCH 103/109] refactor: name report action list decisions Give compound scrolling, loading, and composer focus conditions semantic names while keeping their evaluation in the existing execution paths. Co-authored-by: Codex --- src/hooks/useLinkedReportActionScroll.ts | 29 ++++++---- src/hooks/useReportActionsListModel.ts | 3 +- src/hooks/useReportActionsScroll.ts | 12 +++-- src/pages/inbox/ChatReportActions.tsx | 3 +- .../ComposerWithSuggestions.tsx | 6 +-- .../useShouldSkipComposerAutoFocus.ts | 7 ++- .../ReportActionsListContent.tsx | 3 +- .../SkeletonBelowLinkedAction.tsx | 3 +- .../useReportActionsListData.ts | 3 +- .../useReportActionsListRenderItem.tsx | 24 ++++----- .../useReportActionsListScroll.ts | 54 ++++++++++++------- .../useReportActionsListTelemetry.ts | 3 +- .../useReportActionsNewActionLiveTail.ts | 15 ++++-- tests/ui/ReportActionsListTest.tsx | 3 +- 14 files changed, 106 insertions(+), 62 deletions(-) diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts index 2fbca090ab4a..57bf21174247 100644 --- a/src/hooks/useLinkedReportActionScroll.ts +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -54,20 +54,25 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, }, [isFocused]); const positionLinkedAction = useEffectEvent(() => { - if (!shouldPositionRef.current || targetIndex < 0) { + const shouldSkipPositioning = !shouldPositionRef.current || targetIndex < 0; + if (shouldSkipPositioning) { return; } // Once a reply follows a linked final action, normal live-tail following owns the viewport. - if (wasLastActionRef.current && !isLastAction) { + const didLinkedActionGainReplies = wasLastActionRef.current && !isLastAction; + if (didLinkedActionGainReplies) { shouldPositionRef.current = false; return; } wasLastActionRef.current = isLastAction; - if (Platform.OS === 'android' && !isLastAction && linkedReportActionID) { + const shouldCheckAndroidAnchor = Platform.OS === 'android' && !isLastAction && linkedReportActionID; + if (shouldCheckAndroidAnchor) { const state = getListRef()?.current?.getState?.(); const position = state?.positionByKey(linkedReportActionID); if (position !== undefined && state?.scroll !== undefined && state.contentLength !== undefined && state.scrollLength !== undefined) { - const targetOffset = Math.max(0, Math.min(position + (state.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, state.contentLength - state.scrollLength)); + const linkedActionOffset = position + (state.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET; + const maximumScrollOffset = state.contentLength - state.scrollLength; + const targetOffset = Math.max(0, Math.min(linkedActionOffset, maximumScrollOffset)); // LegendList includes pending native anchoring in its scroll offset. An imperative // scroll to that same offset would apply the height correction twice on Android. if (Math.abs(targetOffset - state.scroll) <= 1) { @@ -84,7 +89,8 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, // Positioning scrolls LegendList imperatively, so it runs here where it can be cancelled. The size and scroll // handlers only bump measurementVersion, so a burst of measurements collapses into one scroll to the latest target. useEffect(() => { - if (!isFocused || !linkedReportActionID || readyListID !== listID || targetIndex < 0 || !shouldPositionRef.current) { + const shouldSkipPositioning = !isFocused || !linkedReportActionID || readyListID !== listID || targetIndex < 0 || !shouldPositionRef.current; + if (shouldSkipPositioning) { return; } @@ -109,7 +115,8 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, // On web, the reader can also scroll with the wheel, the scrollbar or the keyboard. Treat that input on the // scroll element like a native drag, which hands the viewport back to the reader. useEffect(() => { - if (Platform.OS !== 'web' || readyListID !== listID) { + const shouldSkipWebScrollListeners = Platform.OS !== 'web' || readyListID !== listID; + if (shouldSkipWebScrollListeners) { return; } const scrollElement = getListRef()?.current?.getScrollableNode?.(); @@ -134,7 +141,8 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, const onReady = () => setReadyListID(listID); const onScroll = (event: NativeSyntheticEvent) => { - if (!shouldPositionRef.current || readyListID !== listID || !linkedReportActionID || isLastAction) { + const shouldIgnoreLinkedActionScroll = !shouldPositionRef.current || readyListID !== listID || !linkedReportActionID || isLastAction; + if (shouldIgnoreLinkedActionScroll) { return; } const state = getListRef()?.current?.getState?.(); @@ -143,7 +151,9 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, return; } const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; - const targetOffset = Math.max(0, Math.min(position + (state?.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, contentSize.height - layoutMeasurement.height)); + const linkedActionOffset = position + (state?.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET; + const maximumScrollOffset = contentSize.height - layoutMeasurement.height; + const targetOffset = Math.max(0, Math.min(linkedActionOffset, maximumScrollOffset)); // Native anchoring can commit after the row-size correction. Check the resulting viewport // as well, so that late adjustment cannot move a successfully positioned link out of view. if (Math.abs(targetOffset - contentOffset.y) > 1) { @@ -151,7 +161,8 @@ function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, } }; const onItemSizeChanged = ({index, size, previous}: {index: number; size: number; previous: number}) => { - if (!shouldPositionRef.current || readyListID !== listID || index > targetIndex || size === previous) { + const shouldIgnoreItemSizeChange = !shouldPositionRef.current || readyListID !== listID || index > targetIndex || size === previous; + if (shouldIgnoreItemSizeChange) { return; } setMeasurementVersion((version) => version + 1); diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 546eaf1282f6..8e1b42fe526c 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -60,7 +60,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea }); // Cached actions shown offline already have a usable layout. Refresh them in place on reconnect, // and keep this visit loaded if its RAM-only entry briefly disappears during optimistic updates. - const hasOnceLoadedReportActions = useInitial(reportLoadingState?.hasOnceLoadedReportActions || (isOffline && reportActions.length > 0) ? true : undefined); + const hasUsableReportActions = reportLoadingState?.hasOnceLoadedReportActions || (isOffline && reportActions.length > 0); + const hasOnceLoadedReportActions = useInitial(hasUsableReportActions ? true : undefined); const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; const isInitialReportLoadPending = !hasOnceLoadedReportActions && (isReportLoadPending || isLoadingInitialReportActions !== false); const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index af5979d61701..d54678f59d59 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -192,12 +192,14 @@ function useReportActionsScroll({ // Otherwise, prefer the linked action and fall back to the unread marker. // Sending from a link clears the route param to an empty string, which must not suppress unread positioning. let initialScrollKey = linkedReportActionID; - if (!shouldBeAlignedToTop && !linkedReportActionID && unreadMarkerReportActionID) { + const shouldPositionAtUnreadMarker = !shouldBeAlignedToTop && !linkedReportActionID && unreadMarkerReportActionID; + if (shouldPositionAtUnreadMarker) { initialScrollKey = unreadMarkerReportActionID; } // The CREATED action is the top anchor of an aligned-to-top report; scrolling to it is handled by shouldFocusToTopOnMount instead. - if (shouldBeAlignedToTop && initialScrollKey && sortedVisibleReportActionsObjects[initialScrollKey]?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + const isCreatedActionTopAnchor = shouldBeAlignedToTop && initialScrollKey && sortedVisibleReportActionsObjects[initialScrollKey]?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; + if (isCreatedActionTopAnchor) { initialScrollKey = undefined; } @@ -229,7 +231,8 @@ function useReportActionsScroll({ getVisibleIndexRange: () => { const list = getListRef()?.current; // getLayout is the one accessor that does not throw before FlashList has laid out - if (!list || (list.getLayout && !list.getLayout(0))) { + const isListLayoutPending = !list || (list.getLayout && !list.getLayout(0)); + if (isListLayoutPending) { return undefined; } const range = list.computeVisibleIndices?.(); @@ -355,7 +358,8 @@ function useReportActionsScroll({ // page loads, so the link can open near the top like any other instead of at the end of a historical page. Removing // the skeleton with an intermediate page would shrink the content before LegendList sizes the new rows. const [newerActionsSkeletonListID, setNewerActionsSkeletonListID] = useState(); - if (isLinkedTargetLastRow && hasNewerActions && newerActionsSkeletonListID !== listID) { + const shouldStartLinkedActionSkeleton = isLinkedTargetLastRow && hasNewerActions && newerActionsSkeletonListID !== listID; + if (shouldStartLinkedActionSkeleton) { setNewerActionsSkeletonListID(listID); } const shouldShowNewerActionsSkeleton = hasNewerActions && (isLinkedTargetLastRow || newerActionsSkeletonListID === listID); diff --git a/src/pages/inbox/ChatReportActions.tsx b/src/pages/inbox/ChatReportActions.tsx index 5797308d81e1..b283ab7f1f71 100644 --- a/src/pages/inbox/ChatReportActions.tsx +++ b/src/pages/inbox/ChatReportActions.tsx @@ -25,7 +25,8 @@ function ChatReportActions({report}: ChatReportActionsProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isAppLoadPending = useIsAppLoadPending(); const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); - const hasRenderedReportActions = useInitial(isOffline || !isAppLoadPending || isConciergeMainDM ? true : undefined); + const shouldMountReportActions = isOffline || !isAppLoadPending || isConciergeMainDM; + const hasRenderedReportActions = useInitial(shouldMountReportActions ? true : undefined); if (!hasRenderedReportActions) { return ( diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 6f488f360b48..075542bacf76 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -859,9 +859,9 @@ function ComposerWithSuggestions({ // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if ( - !((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused || prevShouldSkipAutoFocus)) - ) { + const shouldRefocusComposer = + (willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused || prevShouldSkipAutoFocus); + if (!shouldRefocusComposer) { return; } diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts b/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts index 2952f08bba4e..2dc024e80638 100644 --- a/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts @@ -32,7 +32,12 @@ function useShouldSkipComposerAutoFocus(report: OnyxEntry): boolean { const [hasExpenseCarousel = false] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, {selector: hasExpenseCarouselSelector}); const isExpenseView = isReportTransactionThread(report) || isOneTransactionReport(report); - return !isReady || (isWeb && routeName !== SCREENS.REPORT && hasExpenseCarousel && isExpenseView); + if (!isReady) { + return true; + } + + const shouldPreserveExpenseNavigation = isWeb && routeName !== SCREENS.REPORT && hasExpenseCarousel && isExpenseView; + return shouldPreserveExpenseNavigation; } export default useShouldSkipComposerAutoFocus; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index 8ce028b09fe6..d8bbae156080 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -195,7 +195,8 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const {setIsReady} = useActionListReady(); useEffect(() => { - setIsReady(shouldShowConciergeWelcome || (!shouldShowInitialViewportSkeleton && loadedInitialViewportListID === listID)); + const isComposerReady = shouldShowConciergeWelcome || (!shouldShowInitialViewportSkeleton && loadedInitialViewportListID === listID); + setIsReady(isComposerReady); }, [listID, loadedInitialViewportListID, setIsReady, shouldShowConciergeWelcome, shouldShowInitialViewportSkeleton]); const {renderItem, extraData} = useReportActionsListRenderItem({ diff --git a/src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx b/src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx index 1eb1d53d3e0b..7dc68313f432 100644 --- a/src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx +++ b/src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx @@ -38,7 +38,8 @@ function SkeletonBelowLinkedAction({linkedActionIndex, hasNewerRows, onNewerRowM const checkNewerRowMeasured = () => { const listState = getListRef()?.current?.getState?.(); // A row without a container is off screen, so there is nothing left to cover. - if (listState?.sizeAtIndex?.(linkedActionIndex + 1) === undefined && listState?.elementAtIndex?.(linkedActionIndex + 1)) { + const shouldWaitForNewerRowMeasurement = listState?.sizeAtIndex?.(linkedActionIndex + 1) === undefined && listState?.elementAtIndex?.(linkedActionIndex + 1); + if (shouldWaitForNewerRowMeasurement) { frame = requestAnimationFrame(checkNewerRowMeasured); return; } diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts index 22f83e575675..87df574c609f 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts @@ -42,7 +42,8 @@ function useReportActionsListData({sortedVisibleReportActions, isOffline, sessio // layouts. The hydrated list then mounts from scratch using the full dataset. const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); const [hasReleasedInitialSnapshot, setHasReleasedInitialSnapshot] = useState(isOffline || !isInitialReportLoadPending || showFullHistory); - if (!hasReleasedInitialSnapshot && (isOffline || !isInitialReportLoadPending || showFullHistory)) { + const shouldReleaseInitialSnapshot = !hasReleasedInitialSnapshot && (isOffline || !isInitialReportLoadPending || showFullHistory); + if (shouldReleaseInitialSnapshot) { setHasReleasedInitialSnapshot(true); } const shouldKeepInitialSnapshot = !hasReleasedInitialSnapshot; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx index ed64bc890c01..e7e7db834c99 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx @@ -141,19 +141,22 @@ function useReportActionsListRenderItem({ const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; const collapsedSystemMessageRun = systemMessageRun && !systemMessageRun.isExpanded ? systemMessageRun : undefined; + const collapsedRunUnreadMarkerReportActionID = + unreadMarkerReportActionID && collapsedSystemMessageRun?.reportActionIDs.includes(unreadMarkerReportActionID) && (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) + ? unreadMarkerReportActionID + : undefined; + const shouldGroupWithPreviousActor = + !isAfterCollapsedRun && + !shouldKeepBillPayActorHeader(reportAction, previousReportAction, reportStable, isInvoiceLinkedToBill) && + !isConsecutiveChronosAutomaticTimerAction(reportActionsToRender, canonicalIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + isConsecutiveActionMadeByPreviousActor(reportActionsToRender, canonicalIndex, isOffline); const row = ( 1} diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index d0106d0ee7f7..ada5d8c18534 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -125,7 +125,8 @@ function useReportActionsListScroll({ // Revealing older actions can make a short session scrollable before the requested history page arrives. // Follow the tail only when the reader was actually there; the action-visible threshold is much wider. shouldRestoreTailAfterHistoryRevealRef.current = distanceFromBottomRef.current <= 1 && !hasNewerActions; - oldestLoadedActionIDAtHistoryRevealRef.current = shouldRestoreTailAfterHistoryRevealRef.current && hasOlderActions ? oldestLoadedActionID : undefined; + const shouldWaitForHistoryPage = shouldRestoreTailAfterHistoryRevealRef.current && hasOlderActions; + oldestLoadedActionIDAtHistoryRevealRef.current = shouldWaitForHistoryPage ? oldestLoadedActionID : undefined; if (shouldRestoreTailAfterHistoryRevealRef.current) { shouldFollowEndOnResizeRef.current = true; } @@ -141,12 +142,15 @@ function useReportActionsListScroll({ return; } const animationFrame = requestAnimationFrame(() => { - if (!shouldRestoreTailAfterHistoryRevealRef.current || !shouldFollowEndOnResizeRef.current) { + const shouldSkipTailRestoration = !shouldRestoreTailAfterHistoryRevealRef.current || !shouldFollowEndOnResizeRef.current; + if (shouldSkipTailRestoration) { return; } legendListRef.current?.scrollToEnd({animated: false}); const hasReceivedHistoryPage = oldestLoadedActionID !== oldestLoadedActionIDAtHistoryRevealRef.current || !hasOlderActions; - if (!oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError))) { + const hasFinishedHistoryReveal = + !oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError)); + if (hasFinishedHistoryReveal) { shouldRestoreTailAfterHistoryRevealRef.current = false; } }); @@ -160,7 +164,8 @@ function useReportActionsListScroll({ const expandedFocusIndex = focusRequestedReportActionID ? (reportActionIDToDisplayIndex.get(focusRequestedReportActionID) ?? -1) : -1; useEffect(() => { - if (expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex)) { + const shouldSkipExpandedActionScroll = expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex); + if (shouldSkipExpandedActionScroll) { return; } @@ -256,12 +261,14 @@ function useReportActionsListScroll({ }; const scheduleEndCorrection = () => { - if (readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined) { + const shouldSkipEndCorrection = readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined; + if (shouldSkipEndCorrection) { return; } endCorrectionFrameRef.current = requestAnimationFrame(() => { endCorrectionFrameRef.current = undefined; - if (readyEndListIDRef.current === listID && shouldFollowEndOnResizeRef.current) { + const shouldFollowListEnd = readyEndListIDRef.current === listID && shouldFollowEndOnResizeRef.current; + if (shouldFollowListEnd) { legendListRef.current?.scrollToEnd({animated: false}); } }); @@ -305,7 +312,8 @@ function useReportActionsListScroll({ }; const loadOlderChatsOnStartReached = () => { - if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { + const shouldSkipOlderPageRequest = showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID; + if (shouldSkipOlderPageRequest) { return; } @@ -320,22 +328,26 @@ function useReportActionsListScroll({ distanceFromBottomRef.current = distanceFromBottom; const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; const previousMetrics = lastScrollMetricsRef.current; - if (readyEndListIDRef.current === listID && !hasNewerActions) { + const canFollowListEnd = readyEndListIDRef.current === listID && !hasNewerActions; + if (canFollowListEnd) { if (distanceFromBottom < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD) { // A list opened at an unread or linked message can settle near the end. Following it from there would // push that message out of view as rows resize, so wait until the list reaches the end or the user drags. - if (initialScrollIndex === undefined || distanceFromBottom <= 1 || userDraggedListIDRef.current === listID) { + const shouldFollowResizingEnd = initialScrollIndex === undefined || distanceFromBottom <= 1 || userDraggedListIDRef.current === listID; + if (shouldFollowResizingEnd) { shouldFollowEndOnResizeRef.current = true; } - } else if ( - (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || - (previousMetrics && - previousMetrics.contentHeight === contentSize.height && - previousMetrics.viewportHeight === layoutMeasurement.height && - endCorrectionFrameRef.current === undefined) - ) { - // A pending history request must not reclaim the end after the reader has moved away. - stopFollowingEnd(); + } else { + const hasReaderMovedAwayFromEnd = + (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || + (previousMetrics && + previousMetrics.contentHeight === contentSize.height && + previousMetrics.viewportHeight === layoutMeasurement.height && + endCorrectionFrameRef.current === undefined); + if (hasReaderMovedAwayFromEnd) { + // A pending history request must not reclaim the end after the reader has moved away. + stopFollowingEnd(); + } } } lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; @@ -384,7 +396,8 @@ function useReportActionsListScroll({ }); const isInitialViewportLoaded = loadedInitialViewportListID === listID; useEffect(() => { - if (!isInitialViewportLoaded || !shouldShowNewerActionsSkeleton) { + const shouldSkipLinkedPageRequest = !isInitialViewportLoaded || !shouldShowNewerActionsSkeleton; + if (shouldSkipLinkedPageRequest) { return; } const frame = requestAnimationFrame(loadNewerChatsBelowLinkedAction); @@ -404,7 +417,8 @@ function useReportActionsListScroll({ const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { onLayout?.(event); const state = legendListRef.current?.getState(); - if (state && readyEndListIDRef.current === listID) { + const canMeasureBottomOffset = state && readyEndListIDRef.current === listID; + if (canMeasureBottomOffset) { const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); scrollOffsetRef.current = distanceFromBottom; distanceFromBottomRef.current = distanceFromBottom; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts index f0bb9e29a6da..a93e56ff81ea 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts @@ -13,7 +13,8 @@ function useReportActionsListTelemetry({reportID, listID, loadedInitialViewportL const {report} = useReportActionsListState(); useEffect(() => { - if (!isFocused || shouldShowInitialViewportSkeleton || loadedInitialViewportListID !== listID) { + const shouldWaitForVisibleViewport = !isFocused || shouldShowInitialViewportSkeleton || loadedInitialViewportListID !== listID; + if (shouldWaitForVisibleViewport) { return; } // onReady positions the list, but only this committed render has removed its cover. diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index a7fb9fdbf9f9..3090c6be035d 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -161,12 +161,14 @@ function useReportActionsNewActionLiveTail({ callback: () => { // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where // they are now in the list. - if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { + const shouldIgnoreActionOutsideActiveReport = !isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute()); + if (shouldIgnoreActionOutsideActiveReport) { return; } // A realtime echo can represent an edit or deletion, whereas local payload-less // notifications from money requests still need the usual live-tail behavior. - if (!action && source === 'realtime') { + const isRealtimeEventWithoutAction = !action && source === 'realtime'; + if (isRealtimeEventWithoutAction) { return; } @@ -183,7 +185,8 @@ function useReportActionsNewActionLiveTail({ } // Release the old pagination anchor so offline sends appear without waiting for openReport. - if (isOffline && linkedReportActionID && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + const shouldReleaseOfflineLink = isOffline && linkedReportActionID && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + if (shouldReleaseOfflineLink) { liveTailJumpRef.current = {stage: 'await_prune'}; setTreatAsNoPaginationAnchor(true); navigation.setParams({reportActionID: ''}); @@ -191,7 +194,8 @@ function useReportActionsNewActionLiveTail({ const index = renderedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { setIsFloatingMessageCounterVisible(false); - if (index >= 0 && index < renderedVisibleReportActions.length - 1) { + const isPreviewBeforeNewestAction = index >= 0 && index < renderedVisibleReportActions.length - 1; + if (isPreviewBeforeNewestAction) { setTimeout(() => { reportScrollManager.scrollToIndex(index); }, 100); @@ -258,7 +262,8 @@ function useReportActionsNewActionLiveTail({ return; } // Offline jumps can only reach the newest cached page, which may not contain the newest server message. - if (!treatAsNoPaginationAnchor || (!isOffline && !hasNewestReportAction)) { + const shouldWaitForLiveTail = !treatAsNoPaginationAnchor || (!isOffline && !hasNewestReportAction); + if (shouldWaitForLiveTail) { return; } diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 6c2944a50d4b..1a44d92c4c27 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1590,7 +1590,8 @@ describe('ReportActionsList (body)', () => { // Then only a loaded latest page is marked read; a cached survey alone cannot prove freshness expect(getCapturedVisibleActions()).not.toContain(survey); expect(markNewestActionAsRead).toHaveBeenCalledTimes(hasNewestPage ? 1 : 0); - expect(requestLiveTailJump).toHaveBeenCalledTimes(!hasNewestPage && !hasUnreadMarker ? 1 : 0); + const shouldRequestLiveTailJump = !hasNewestPage && !hasUnreadMarker; + expect(requestLiveTailJump).toHaveBeenCalledTimes(shouldRequestLiveTailJump ? 1 : 0); if (hasUnreadMarker) { expect(scrollToIndex).toHaveBeenCalledWith(0, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); } else if (hasNewestPage) { From 3fe76b02d0fb59888b81249888de749c6004446b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Nov 2026 13:20:05 +0100 Subject: [PATCH 104/109] chore: rebuild the LegendList patches from their upstream PRs Each LegendList patch is now the build output of its upstream PR, so the patches change exactly what the PRs change, in every bundle the PRs touch. - 001 and 003 now also patch the native bundles, as their PRs change shared code. - 002 adds the explicit maintainVisibleContentPosition anchor option that LegendApp/legend-list#597 now proposes, instead of anchoring the last visible item in every list with alignItemsAtEnd. - 004 contains LegendApp/legend-list#600, which now also skips deferred adjustments that a later native scroll already includes and moves the initial scroll target with each adjustment. - 006 keeps only the silent-retry fix from LegendApp/legend-list#604. Co-Authored-By: Claude Opus 5.5 --- ...+001+apply-pending-anchor-correction.patch | 60 ++- ...p+list+3.7.2+002+add-mvcp-end-anchor.patch | 482 ++++++++++++++++++ ...d-aligned-lists-to-last-visible-item.patch | 108 ---- ...chor-data-changes-after-scroll-jumps.patch | 44 +- ...+land-initial-mount-adjustments-once.patch | 472 +++++++++++++++++ ...ist-after-initial-scroll-adjustments.patch | 240 --------- ...05+keep-scroll-adjust-anchor-precise.patch | 12 +- ...finish-silent-initial-scroll-retries.patch | 282 ++++++++++ ...keep-native-initial-scroll-on-target.patch | 228 --------- patches/@legendapp/list/details.md | 36 +- 10 files changed, 1351 insertions(+), 613 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch index 19a3139e8df6..19e8ee2cd261 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch @@ -1,5 +1,57 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 4af685dfb96..92b387b085f 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -4769,7 +4769,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4869,6 +4870,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 9ebfddc183a..30105d17d72 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -4748,7 +4748,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4848,6 +4849,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 5155454..c102661 100644 +index 515545471e0..c102661fc18 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js @@ -4843,7 +4843,8 @@ function calculateItemsInView(ctx, params = {}) { @@ -25,7 +77,7 @@ index 5155454..c102661 100644 if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index ce08abe..ff71fd6 100644 +index ce08abe7ee6..ff71fd62640 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs @@ -4822,7 +4822,8 @@ function calculateItemsInView(ctx, params = {}) { @@ -51,7 +103,7 @@ index ce08abe..ff71fd6 100644 if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 5155454..c102661 100644 +index 515545471e0..c102661fc18 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js @@ -4843,7 +4843,8 @@ function calculateItemsInView(ctx, params = {}) { @@ -77,7 +129,7 @@ index 5155454..c102661 100644 if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index ce08abe..ff71fd6 100644 +index ce08abe7ee6..ff71fd62640 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs @@ -4822,7 +4822,8 @@ function calculateItemsInView(ctx, params = {}) { diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch new file mode 100644 index 000000000000..a7e06efae9a0 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch @@ -0,0 +1,482 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 92b387b085f..e687e9c0a41 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1800,9 +1800,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; + const isWeb = Platform.OS === "web"; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1847,11 +1849,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8236,6 +8243,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 30105d17d72..6271d782ea7 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1779,9 +1779,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; + const isWeb = Platform.OS === "web"; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1826,11 +1828,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8215,6 +8222,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index c102661fc18..2b3171a6f4c 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1704,8 +1704,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1743,11 +1745,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -9018,6 +9025,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index ff71fd62640..dbd6fbbf3e8 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -1683,8 +1683,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1722,11 +1724,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8997,6 +9004,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index c102661fc18..2b3171a6f4c 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1704,8 +1704,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1743,11 +1745,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -9018,6 +9025,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index ff71fd62640..dbd6fbbf3e8 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -1683,8 +1683,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1722,11 +1724,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8997,6 +9004,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.d.ts b/node_modules/@legendapp/list/react-native.d.ts +index 983e937cf5c..72e2d573b77 100644 +--- a/node_modules/@legendapp/list/react-native.d.ts ++++ b/node_modules/@legendapp/list/react-native.d.ts +@@ -3,6 +3,7 @@ import { Key, Dispatch, SetStateAction } from 'react'; + import { ScrollViewProps, NativeSyntheticEvent as NativeSyntheticEvent$1, NativeScrollEvent as NativeScrollEvent$1, ScrollView, StyleProp as StyleProp$1, ViewStyle as ViewStyle$1, ScrollViewComponent, ScrollResponderMixin, Insets as Insets$1, View } from 'react-native'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -292,6 +293,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -494,6 +496,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/react-native.web.d.ts b/node_modules/@legendapp/list/react-native.web.d.ts +index 358fcd7faa5..455c5857a8b 100644 +--- a/node_modules/@legendapp/list/react-native.web.d.ts ++++ b/node_modules/@legendapp/list/react-native.web.d.ts +@@ -30,6 +30,7 @@ interface ScrollViewMethods { + } + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -319,6 +320,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -521,6 +523,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/react.d.ts b/node_modules/@legendapp/list/react.d.ts +index 358fcd7faa5..455c5857a8b 100644 +--- a/node_modules/@legendapp/list/react.d.ts ++++ b/node_modules/@legendapp/list/react.d.ts +@@ -30,6 +30,7 @@ interface ScrollViewMethods { + } + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -319,6 +320,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -521,6 +523,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/animated.d.ts b/node_modules/@legendapp/list/animated.d.ts +index c519061902a..1397ff21793 100644 +--- a/node_modules/@legendapp/list/animated.d.ts ++++ b/node_modules/@legendapp/list/animated.d.ts +@@ -3,6 +3,7 @@ import { Key } from 'react'; + import { ScrollViewProps, NativeSyntheticEvent as NativeSyntheticEvent$1, NativeScrollEvent as NativeScrollEvent$1, ScrollView, StyleProp as StyleProp$1, ViewStyle as ViewStyle$1, ScrollViewComponent, ScrollResponderMixin, Insets as Insets$1, Animated } from 'react-native'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -292,6 +293,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -494,6 +496,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/keyboard.d.ts b/node_modules/@legendapp/list/keyboard.d.ts +index 2aa690be174..32f72309087 100644 +--- a/node_modules/@legendapp/list/keyboard.d.ts ++++ b/node_modules/@legendapp/list/keyboard.d.ts +@@ -6,6 +6,7 @@ import { SharedValue } from 'react-native-reanimated'; + import { AnimatedLegendListProps } from '@legendapp/list/reanimated'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +diff --git a/node_modules/@legendapp/list/keyboard-legacy.d.ts b/node_modules/@legendapp/list/keyboard-legacy.d.ts +index 0d2e865f8e7..61e36137d52 100644 +--- a/node_modules/@legendapp/list/keyboard-legacy.d.ts ++++ b/node_modules/@legendapp/list/keyboard-legacy.d.ts +@@ -5,6 +5,7 @@ import { ScrollEvent, ScrollHandlerProcessed } from 'react-native-reanimated'; + import { AnimatedLegendListProps } from '@legendapp/list/reanimated'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +diff --git a/node_modules/@legendapp/list/reanimated.d.ts b/node_modules/@legendapp/list/reanimated.d.ts +index 5b568f2d397..f6aa70389be 100644 +--- a/node_modules/@legendapp/list/reanimated.d.ts ++++ b/node_modules/@legendapp/list/reanimated.d.ts +@@ -4,6 +4,7 @@ import Reanimated, { SharedValue } from 'react-native-reanimated'; + import { ScrollViewProps, NativeSyntheticEvent as NativeSyntheticEvent$1, NativeScrollEvent as NativeScrollEvent$1, ScrollView, StyleProp as StyleProp$1, ViewStyle as ViewStyle$1, ScrollViewComponent, ScrollResponderMixin, Insets as Insets$1 } from 'react-native'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -293,6 +294,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -495,6 +497,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/section-list.d.ts b/node_modules/@legendapp/list/section-list.d.ts +index 94edf333f0d..aab8b29dc8d 100644 +--- a/node_modules/@legendapp/list/section-list.d.ts ++++ b/node_modules/@legendapp/list/section-list.d.ts +@@ -60,6 +60,7 @@ type BuildSectionListDataResult = { + }; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -349,6 +350,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -551,6 +553,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch deleted file mode 100644 index bad48a60d7d7..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch +++ /dev/null @@ -1,108 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index c102661..99cac83 100644 ---- a/node_modules/@legendapp/list/react-native.web.js -+++ b/node_modules/@legendapp/list/react-native.web.js -@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; -diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index ff71fd6..a7a89dd 100644 ---- a/node_modules/@legendapp/list/react-native.web.mjs -+++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; -diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index c102661..99cac83 100644 ---- a/node_modules/@legendapp/list/react.js -+++ b/node_modules/@legendapp/list/react.js -@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; -diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index ff71fd6..a7a89dd 100644 ---- a/node_modules/@legendapp/list/react.mjs -+++ b/node_modules/@legendapp/list/react.mjs -@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch index 86a890e5d799..22a71ea829e0 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch @@ -1,8 +1,36 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index e687e9c0a41..e08a0f5d005 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -7048,6 +7048,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 6271d782ea7..5eac366737d 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -7027,6 +7027,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 99cac83..30d9757 100644 +index 2b3171a6f4c..b7c3ecb0d71 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7886,6 +7886,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; @@ -13,10 +41,10 @@ index 99cac83..30d9757 100644 if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index a7a89dd..f55dea9 100644 +index dbd6fbbf3e8..0831a154ba8 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7865,6 +7865,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; @@ -27,10 +55,10 @@ index a7a89dd..f55dea9 100644 if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 99cac83..30d9757 100644 +index 2b3171a6f4c..b7c3ecb0d71 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7886,6 +7886,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; @@ -41,10 +69,10 @@ index 99cac83..30d9757 100644 if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index a7a89dd..f55dea9 100644 +index dbd6fbbf3e8..0831a154ba8 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7865,6 +7865,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch new file mode 100644 index 000000000000..c08c1313b69e --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch @@ -0,0 +1,472 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index e08a0f5d005..c9df90ee7b9 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1063,9 +1063,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1073,6 +1070,7 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state, positionDiff); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); +@@ -1104,10 +1102,33 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ const scrollingTo = state.scrollingTo; ++ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ scrollingTo.offset += positionDiff; ++ if (scrollingTo.targetOffset !== void 0) { ++ scrollingTo.targetOffset += positionDiff; ++ } ++ const watchdog = initialScrollWatchdog.get(state); ++ if (watchdog) { ++ watchdog.targetOffset += positionDiff; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1529,7 +1550,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2560,8 +2583,11 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _a3, _b; ++ if (Platform.OS !== "web") { ++ ctx.state.nativeScrollEpoch = ((_a3 = ctx.state.nativeScrollEpoch) != null ? _a3 : 0) + 1; ++ } ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +@@ -2642,6 +2668,7 @@ function checkFinishedScrollFallback(ctx) { + + // src/core/doScrollTo.native.ts + function doScrollTo(ctx, params) { ++ var _a3; + const state = ctx.state; + const { animated, horizontal, isInitialScroll, offset } = params; + const isAnimated = !!animated; +@@ -2652,6 +2679,7 @@ function doScrollTo(ctx, params) { + } + if (!params.isAdjustment) { + state.userScrollContentSize = void 0; ++ state.nativeScrollEpoch = ((_a3 = state.nativeScrollEpoch) != null ? _a3 : 0) + 1; + } + scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); + if (isInitialScroll) { +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 5eac366737d..7de20403d1d 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1042,9 +1042,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1052,6 +1049,7 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state, positionDiff); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); +@@ -1083,10 +1081,33 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ const scrollingTo = state.scrollingTo; ++ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ scrollingTo.offset += positionDiff; ++ if (scrollingTo.targetOffset !== void 0) { ++ scrollingTo.targetOffset += positionDiff; ++ } ++ const watchdog = initialScrollWatchdog.get(state); ++ if (watchdog) { ++ watchdog.targetOffset += positionDiff; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1508,7 +1529,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2539,8 +2562,11 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _a3, _b; ++ if (Platform.OS !== "web") { ++ ctx.state.nativeScrollEpoch = ((_a3 = ctx.state.nativeScrollEpoch) != null ? _a3 : 0) + 1; ++ } ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +@@ -2621,6 +2647,7 @@ function checkFinishedScrollFallback(ctx) { + + // src/core/doScrollTo.native.ts + function doScrollTo(ctx, params) { ++ var _a3; + const state = ctx.state; + const { animated, horizontal, isInitialScroll, offset } = params; + const isAnimated = !!animated; +@@ -2631,6 +2658,7 @@ function doScrollTo(ctx, params) { + } + if (!params.isAdjustment) { + state.userScrollContentSize = void 0; ++ state.nativeScrollEpoch = ((_a3 = state.nativeScrollEpoch) != null ? _a3 : 0) + 1; + } + scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); + if (isInitialScroll) { +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index b7c3ecb0d71..10dd9b9c9bb 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1011,15 +1008,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1435,7 +1448,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2449,8 +2464,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 0831a154ba8..0ba06dac531 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -990,15 +987,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1414,7 +1427,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2428,8 +2443,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index b7c3ecb0d71..10dd9b9c9bb 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1011,15 +1008,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1435,7 +1448,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2449,8 +2464,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 0831a154ba8..0ba06dac531 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -990,15 +987,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1414,7 +1427,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2428,8 +2443,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch deleted file mode 100644 index 3ced246136c8..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch +++ /dev/null @@ -1,240 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index 4af685d..8fd2025 100644 ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -1063,9 +1063,6 @@ function requestAdjust(ctx, positionDiff, source) { - ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); - } else { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1104,7 +1101,13 @@ function requestAdjust(ctx, positionDiff, source) { - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1529,7 +1532,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index 9ebfddc..53fb116 100644 ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -1042,9 +1042,6 @@ function requestAdjust(ctx, positionDiff, source) { - ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); - } else { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1083,7 +1080,13 @@ function requestAdjust(ctx, positionDiff, source) { - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1508,7 +1511,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 30d9757..b782503 100644 ---- a/node_modules/@legendapp/list/react-native.web.js -+++ b/node_modules/@legendapp/list/react-native.web.js -@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index f55dea9..a1bcd90 100644 ---- a/node_modules/@legendapp/list/react-native.web.mjs -+++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 30d9757..b782503 100644 ---- a/node_modules/@legendapp/list/react.js -+++ b/node_modules/@legendapp/list/react.js -@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index f55dea9..a1bcd90 100644 ---- a/node_modules/@legendapp/list/react.mjs -+++ b/node_modules/@legendapp/list/react.mjs -@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch index 8c8072e4d7a7..1d0eeaac24e7 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch @@ -1,29 +1,25 @@ diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index c9df90ee7b9..17727711406 100644 --- a/node_modules/@legendapp/list/react-native.js +++ b/node_modules/@legendapp/list/react-native.js -@@ -6636,7 +6636,10 @@ +@@ -6672,7 +6672,7 @@ function getAutoOtherAxisStyle({ function ScrollAdjust() { var _a3; const ctx = useStateContext(); - const bias = 1e7; -+ // The bias keeps this anchor below the scroll offset, so native maintainVisibleContentPosition always anchors to it. -+ // Native layout stores positions as 32-bit floats, which hold 10,000,000 only to the nearest whole point. That -+ // rounded each adjustment by up to half a point, and the visible items moved by the difference. + const bias = 1e6; const [scrollAdjust, scrollAdjustUserOffset] = useArr$(["scrollAdjust", "scrollAdjustUserOffset"]); const scrollOffset = (scrollAdjust || 0) + (scrollAdjustUserOffset || 0) + bias; const horizontal = !!((_a3 = ctx.state) == null ? void 0 : _a3.props.horizontal); diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 7de20403d1d..a895a1aad01 100644 --- a/node_modules/@legendapp/list/react-native.mjs +++ b/node_modules/@legendapp/list/react-native.mjs -@@ -6615,7 +6615,10 @@ +@@ -6651,7 +6651,7 @@ function getAutoOtherAxisStyle({ function ScrollAdjust() { var _a3; const ctx = useStateContext(); - const bias = 1e7; -+ // The bias keeps this anchor below the scroll offset, so native maintainVisibleContentPosition always anchors to it. -+ // Native layout stores positions as 32-bit floats, which hold 10,000,000 only to the nearest whole point. That -+ // rounded each adjustment by up to half a point, and the visible items moved by the difference. + const bias = 1e6; const [scrollAdjust, scrollAdjustUserOffset] = useArr$(["scrollAdjust", "scrollAdjustUserOffset"]); const scrollOffset = (scrollAdjust || 0) + (scrollAdjustUserOffset || 0) + bias; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch new file mode 100644 index 000000000000..9eccd66abcac --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch @@ -0,0 +1,282 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 17727711406..3ccffa79110 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -518,7 +518,9 @@ var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2460,11 +2462,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2487,8 +2490,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2636,10 +2639,13 @@ function checkFinishedScrollFallback(ctx) { + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ const returnToTarget = () => { ++ var _a4, _b2; ++ state.silentInitialScrollReturn = void 0; ++ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); ++ }; ++ state.silentInitialScrollReturn = returnToTarget; ++ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index a895a1aad01..676c117a45a 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -497,7 +497,9 @@ var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2439,11 +2441,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2466,8 +2469,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2615,10 +2618,13 @@ function checkFinishedScrollFallback(ctx) { + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ const returnToTarget = () => { ++ var _a4, _b2; ++ state.silentInitialScrollReturn = void 0; ++ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); ++ }; ++ state.silentInitialScrollReturn = returnToTarget; ++ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 10dd9b9c9bb..b6f2da18e38 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -459,7 +459,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2342,11 +2344,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2369,8 +2372,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 0ba06dac531..7413734a88e 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -438,7 +438,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2321,11 +2323,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2348,8 +2351,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 10dd9b9c9bb..b6f2da18e38 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -459,7 +459,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2342,11 +2344,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2369,8 +2372,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 0ba06dac531..7413734a88e 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -438,7 +438,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2321,11 +2323,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2348,8 +2351,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch deleted file mode 100644 index 2bc501906ab1..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch +++ /dev/null @@ -1,228 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -518,7 +518,9 @@ - var ENABLE_DEBUG_VIEW = IS_DEV && false; - - // src/core/cancelImperativeScroll.ts --function cancelScrollCompletionChecks({ scheduledWork }) { -+function cancelScrollCompletionChecks(state) { -+ const { scheduledWork } = state; -+ state.silentInitialScrollReturn = void 0; - scheduledWork.cancel("checkFinishedScrollFrame"); - scheduledWork.cancel("checkFinishedScrollRetryFrame"); - scheduledWork.cancel("checkFinishedScrollFallback"); -@@ -1051,6 +1053,20 @@ - } - - // src/utils/requestAdjust.ts -+function shiftNativeInitialScrollTarget(state, positionDiff) { -+ const scrollingTo = state.scrollingTo; -+ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { -+ return; -+ } -+ scrollingTo.offset += positionDiff; -+ if (scrollingTo.targetOffset !== void 0) { -+ scrollingTo.targetOffset += positionDiff; -+ } -+ const watchdog = initialScrollWatchdog.get(state); -+ if (watchdog) { -+ watchdog.targetOffset += positionDiff; -+ } -+} - function requestAdjust(ctx, positionDiff, source) { - var _a3; - const state = ctx.state; -@@ -1070,6 +1086,7 @@ - state.userScrollContentSize += positionDiff; - } - state.scrollForNextCalculateItemsInView = void 0; -+ shiftNativeInitialScrollTarget(state, positionDiff); - const readyToRender = peek$(ctx, "readyToRender"); - if (readyToRender) { - doit(); -@@ -1101,8 +1118,11 @@ - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -+ const nativeScrollEpoch = state.nativeScrollEpoch; - requestAnimationFrame(() => { -- doit(); -+ if (state.nativeScrollEpoch === nativeScrollEpoch) { -+ doit(); -+ } - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; - if (!state.adjustingFromInitialMount) { - setInitialRenderState(ctx, {}); -@@ -2435,9 +2455,10 @@ - - // src/core/finishScrollTo.ts - function finishScrollTo(ctx) { -- var _a3, _b, _c; -+ var _a3, _b, _c, _d; - const state = ctx.state; - if (state == null ? void 0 : state.scrollingTo) { -+ (_d = state.silentInitialScrollReturn) == null ? void 0 : _d.call(state); - cancelScrollCompletionChecks(state); - const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; - if (resolvePendingScroll) { -@@ -2558,7 +2579,10 @@ - } - } - function scrollToFallbackOffset(ctx, offset, animated = false) { -- var _a3; -+ var _a3, _b; -+ if (Platform.OS !== "web") { -+ ctx.state.nativeScrollEpoch = ((_b = ctx.state.nativeScrollEpoch) != null ? _b : 0) + 1; -+ } - (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); - } - function checkFinishedScrollFallback(ctx) { -@@ -2608,10 +2632,13 @@ - const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; - initialScrollCompletion.markSilentInitialScrollRetry(state); - scrollToFallbackOffset(ctx, jiggleOffset); -- state.scheduledWork.frame( -- () => scrollToFallbackOffset(ctx, targetOffset), -- "checkFinishedScrollRetryFrame" -- ); -+ const returnToTarget = () => { -+ var _a4, _b2; -+ state.silentInitialScrollReturn = void 0; -+ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); -+ }; -+ state.silentInitialScrollReturn = returnToTarget; -+ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -@@ -2640,6 +2667,7 @@ - - // src/core/doScrollTo.native.ts - function doScrollTo(ctx, params) { -+ var _nativeScrollEpoch; - const state = ctx.state; - const { animated, horizontal, isInitialScroll, offset } = params; - const isAnimated = !!animated; -@@ -2650,6 +2678,7 @@ - } - if (!params.isAdjustment) { - state.userScrollContentSize = void 0; -+ state.nativeScrollEpoch = ((_nativeScrollEpoch = state.nativeScrollEpoch) != null ? _nativeScrollEpoch : 0) + 1; - } - scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); - if (isInitialScroll) { -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -497,7 +497,9 @@ - var ENABLE_DEBUG_VIEW = IS_DEV && false; - - // src/core/cancelImperativeScroll.ts --function cancelScrollCompletionChecks({ scheduledWork }) { -+function cancelScrollCompletionChecks(state) { -+ const { scheduledWork } = state; -+ state.silentInitialScrollReturn = void 0; - scheduledWork.cancel("checkFinishedScrollFrame"); - scheduledWork.cancel("checkFinishedScrollRetryFrame"); - scheduledWork.cancel("checkFinishedScrollFallback"); -@@ -1030,6 +1032,20 @@ - } - - // src/utils/requestAdjust.ts -+function shiftNativeInitialScrollTarget(state, positionDiff) { -+ const scrollingTo = state.scrollingTo; -+ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { -+ return; -+ } -+ scrollingTo.offset += positionDiff; -+ if (scrollingTo.targetOffset !== void 0) { -+ scrollingTo.targetOffset += positionDiff; -+ } -+ const watchdog = initialScrollWatchdog.get(state); -+ if (watchdog) { -+ watchdog.targetOffset += positionDiff; -+ } -+} - function requestAdjust(ctx, positionDiff, source) { - var _a3; - const state = ctx.state; -@@ -1049,6 +1065,7 @@ - state.userScrollContentSize += positionDiff; - } - state.scrollForNextCalculateItemsInView = void 0; -+ shiftNativeInitialScrollTarget(state, positionDiff); - const readyToRender = peek$(ctx, "readyToRender"); - if (readyToRender) { - doit(); -@@ -1080,8 +1097,11 @@ - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -+ const nativeScrollEpoch = state.nativeScrollEpoch; - requestAnimationFrame(() => { -- doit(); -+ if (state.nativeScrollEpoch === nativeScrollEpoch) { -+ doit(); -+ } - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; - if (!state.adjustingFromInitialMount) { - setInitialRenderState(ctx, {}); -@@ -2414,9 +2434,10 @@ - - // src/core/finishScrollTo.ts - function finishScrollTo(ctx) { -- var _a3, _b, _c; -+ var _a3, _b, _c, _d; - const state = ctx.state; - if (state == null ? void 0 : state.scrollingTo) { -+ (_d = state.silentInitialScrollReturn) == null ? void 0 : _d.call(state); - cancelScrollCompletionChecks(state); - const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; - if (resolvePendingScroll) { -@@ -2537,7 +2558,10 @@ - } - } - function scrollToFallbackOffset(ctx, offset, animated = false) { -- var _a3; -+ var _a3, _b; -+ if (Platform.OS !== "web") { -+ ctx.state.nativeScrollEpoch = ((_b = ctx.state.nativeScrollEpoch) != null ? _b : 0) + 1; -+ } - (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); - } - function checkFinishedScrollFallback(ctx) { -@@ -2587,10 +2611,13 @@ - const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; - initialScrollCompletion.markSilentInitialScrollRetry(state); - scrollToFallbackOffset(ctx, jiggleOffset); -- state.scheduledWork.frame( -- () => scrollToFallbackOffset(ctx, targetOffset), -- "checkFinishedScrollRetryFrame" -- ); -+ const returnToTarget = () => { -+ var _a4, _b2; -+ state.silentInitialScrollReturn = void 0; -+ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); -+ }; -+ state.silentInitialScrollReturn = returnToTarget; -+ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -@@ -2619,6 +2646,7 @@ - - // src/core/doScrollTo.native.ts - function doScrollTo(ctx, params) { -+ var _nativeScrollEpoch; - const state = ctx.state; - const { animated, horizontal, isInitialScroll, offset } = params; - const isAnimated = !!animated; -@@ -2629,6 +2657,7 @@ - } - if (!params.isAdjustment) { - state.userScrollContentSize = void 0; -+ state.nativeScrollEpoch = ((_nativeScrollEpoch = state.nativeScrollEpoch) != null ? _nativeScrollEpoch : 0) + 1; - } - scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); - if (isInitialScroll) { diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index b3446ceaf904..551f199e10b3 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -1,55 +1,57 @@ # `@legendapp/list` patches +Each patch is the build output of its upstream PR, so it changes exactly what that PR changes. Remove a patch once a LegendList release contains its PR. + ### [@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch) - Reason: On web, LegendList holds a scroll anchor for a moment after a data change and defers the scroll correction for item size changes to the next frame. Any other pass that repositions items before that frame, such as the one a scroll event triggers, moves the visible messages without the correction. They jump for a frame and only partly return. With this patch, the first pass that repositions items applies the pending correction. -- Scope: Only the web bundles, and only while a deferred item-size correction is pending. +- Scope: All bundles. Only web defers the correction, so native lists are unaffected. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/596 - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, open a chat with more history than the first page. Scroll to the top until older messages load. The message at the top of the screen should stay in place instead of jumping and snapping back. -### [@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch) +### [@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch](@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch) -- Reason: When items change size or data changes, LegendList keeps the first visible item in place. In a chat, the oldest loaded message shows the sender's avatar, and it loses that header once older messages from the same sender load. Its height shrinks, so every message below it moves up. An inverted list keeps the bottom in place instead. For lists with `alignItemsAtEnd`, this patch anchors the last visible item, so a size change above it only moves the content above. -- Scope: Only the web bundles, and only lists with `alignItemsAtEnd`. Other lists still anchor the first visible item. +- Reason: When items change size or data changes, LegendList keeps the first visible item in place. In a chat, the oldest loaded message shows the sender's avatar, and it loses that header once older messages from the same sender load. Its height shrinks, so every message below it moves up. An inverted list keeps the bottom in place instead. This patch adds `anchor: "end"` to `maintainVisibleContentPosition`, which keeps the last visible item in place, so a size change above it only moves the content above. +- Scope: All bundles and type definitions, and only lists that pass `anchor: "end"`. Other lists still keep the first visible item in place. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/597 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: On web, clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. +- Reproduction: Clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. ### [@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch](@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch) -- Reason: After a large scroll jump, such as a fast scrollbar drag, LegendList repositions newly measured items without keeping anything in place, so the region the user jumped to can lay out. This lasts until it has measured every item it rendered since the jump. If older messages load in the meantime, their measurements push the visible messages down by a few hundred pixels for a frame. This patch ends that state when the data changes, so the new messages are anchored like any other data change. -- Scope: Only the web bundles, and only lists that keep their visible content in place on data changes. +- Reason: After a large scroll jump, such as a fast scrollbar drag or fling, LegendList repositions newly measured items without keeping anything in place, so the region the user jumped to can lay out. This lasts until it has measured every item it rendered since the jump. If older messages load in the meantime, their measurements push the visible messages down by a few hundred pixels for a frame. This patch ends that state when the data changes, so the new messages are anchored like any other data change. +- Scope: All bundles, and only lists that keep their visible content in place on data changes. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/598 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat with a long history. Drag the scrollbar thumb quickly up and down a few times, then hold it near the top until older messages load. The messages on screen should not move. -### [@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch) +### [@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch](@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch) -- Reason: Before a list is ready, LegendList moves its items as soon as they are measured but applies the matching native scroll adjustment one frame later. It could mark the list ready, and show it, before that frame ran. On iOS, a chat that opens at a linked message then shows the message at the top of the list and moves it down by about 45 points. With this patch, the list becomes ready only after those adjustments have run. -- Scope: All bundles, and only during a list's initial layout while a scroll adjustment requested before the list was ready is still pending. +- Reason: Before a list is ready, LegendList moves its items as soon as they are measured but applies the matching native scroll adjustment one frame later. It could mark the list ready, and show it, before that frame ran. An initial scroll dispatched in between also already lands on the adjusted offset, so the late adjustment moved the list a second time, and LegendList's retries reissued the offset recorded when the scroll started. A chat that opens at a linked message then shows the message at the top of the list and moves it down. With this patch, the list becomes ready only after those adjustments have run, skips an adjustment that a later native scroll already includes, and moves the target of the initial scroll with each adjustment. +- Scope: All bundles, and only during a list's initial layout. Skipping adjustments and moving the target only apply on native. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/600 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: On iOS, open a chat from an LHN row with a Pay or Approve action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. +- Reproduction: On iOS or Android, clear the cache and open a chat from an LHN row with a Pay, Approve, or Fix action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. ### [@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch](@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch) -- Reason: LegendList keeps the visible items in place with a hidden anchor view placed 10,000,000 points down. Native layout stores positions as 32-bit floats, which hold that value only to the nearest whole point, so each scroll adjustment was rounded while the items moved by the exact amount. On Android, a chat that opens at a linked message moved up by 1–2 px when the rows above it were measured. This patch moves the anchor to 1,000,000 points, where its position stays precise to 1/16 of a point. +- Reason: LegendList keeps the visible items in place with a hidden anchor view placed 10,000,000 points down. Native layout stores positions as 32-bit floats, which hold that value only to the nearest whole point, so each scroll adjustment was rounded while the items moved by the exact amount. On Android, a chat that opens at a linked message moved up by 1 or 2 px when the rows above it were measured. This patch moves the anchor to 1,000,000 points, where its position stays precise to 1/16 of a point. Adjustments move the anchor along with the content, so the list can still scroll through 1,000,000 points of content that loaded without one, such as the first page and new messages. - Scope: Only the native bundles. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/602 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On Android, open a chat from an LHN row with a Fix action, so it opens at the linked expense. When the Latest messages button appears, the messages on screen should not move. -### [@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch](@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch) +### [@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch](@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch) -- Reason: Before a list is ready, LegendList moves its item positions right away but moves its native scroll anchor a frame later. An initial scroll dispatched in between already lands on the adjusted offset, so the late anchor move shifted the list again, and LegendList's retries then reissued the offset recorded when the scroll started. A retry for scrolls Android reports silently also left the list one point off when the scroll finished first. On Android, a chat that opens at a linked message appeared with the message at the top of the list and then moved down to its offset. This patch skips anchor moves that a later native scroll already includes, moves the pending initial-scroll target with each adjustment, and completes the retry before the scroll finishes. -- Scope: Only the native bundles, and only while an initial scroll is in flight or the list is not ready yet. -- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/604 +- Reason: When Android dispatches an initial scroll without reporting a scroll event, LegendList retries it by moving the list one point away and back on the next frame. If another check finished the scroll first, the list stayed one point off its target, and the return used the offset recorded before adjustments moved the target. With this patch, finishing the scroll completes a pending return, and the return goes to the current target. +- Scope: All bundles, and only initial scrolls that LegendList retries because they reported no scroll event. That only happens on native. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/604, which builds on https://github.com/LegendApp/legend-list/pull/600 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: On Android, clear the cache and open a chat from an LHN row with a Fix action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. +- Reproduction: On Android, clear the cache and open a chat from an LHN row with a Fix action, so it opens at the linked expense. The highlighted expense should come to rest a little below the top of the list and stay there. From 806eb27c08b30c1210b0c892949edc55cb166603 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Nov 2026 13:22:24 +0100 Subject: [PATCH 105/109] fix: keep the last visible chat message in place LegendList patch 002 now follows LegendApp/legend-list#597, which makes anchoring the last visible item an explicit maintainVisibleContentPosition option instead of deriving it from alignItemsAtEnd. The chat list passes anchor "end" whenever it aligns its messages to the end, so rows that resize or load above the reader's position keep moving only the content above it. Co-Authored-By: Claude Opus 5.5 --- .../report/ReportActionsList/ReportActionsListContent.tsx | 3 ++- tests/ui/ReportActionsListTest.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index d8bbae156080..dd3712e5628b 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -307,7 +307,8 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) key={listID} // Keep the existing pixel-based new-message policy in useReportActionsScroll. maintainScrollAtEnd={false} - maintainVisibleContentPosition + // Chats are read from the bottom, so keep the last visible message in place when rows above it resize or load. + maintainVisibleContentPosition={{data: true, size: true, anchor: shouldBeAlignedToTop ? 'start' : 'end'}} /> {shouldShowInitialViewportSkeleton && ( { renderReportActionsList(); expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); - expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toEqual({data: true, size: true, anchor: 'end'}); }); it('remounts the list when the initial report actions finish hydrating', async () => { From f20206b595658d0223ac4287f9d57ba115cd0575 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Nov 2026 13:20:06 +0100 Subject: [PATCH 106/109] docs: note LegendList's new anchor offset in the Android MVCP patch notes LegendList patch 005 places its scroll anchor at 1,000,000 points instead of 10,000,000. Co-Authored-By: Claude Opus 5.5 --- patches/react-native/details.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index daec695c5aa5..29d5fa7d8346 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -12,7 +12,7 @@ - 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. LegendList 3.7 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. + 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. LegendList places the anchor at its adjustment offset plus 1,000,000 points, beyond the viewport (10,000,000 before `@legendapp/list` patch 005). The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. - Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. - Upstream PR/issue: https://github.com/react/react-native/pull/57959 merged on 2026-10-28 as [9bc1a28a9623419d62eb350acd0c172644e2c022](https://github.com/react/react-native/commit/9bc1a28a9623419d62eb350acd0c172644e2c022). It ports the original Android MVCP proposal and includes z-index anchor selection and support for LegendList's positioned, zero-sized anchor. - Expected release: React Native 0.89.0, [currently scheduled for 2026-12-07](https://reactnative.dev/releases/overview), unless cherry-picked into an earlier release. The fix landed after the 0.88 branch cut and before the scheduled 0.89 branch cut on 2026-11-03. From d804f68a068cb95771b0f32eaa32e9ab95992d6e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Nov 2026 13:22:35 +0100 Subject: [PATCH 107/109] fix: replace LegendList patch 007 with its upstream version The patch now is the build output of LegendApp/legend-list#607. That PR applies LegendList's header-size adjustment on every platform and reuses the Android clamp handling that item resizes already have, instead of the header-only copy of it. The patch also gets its details.md entry. Co-Authored-By: Claude Opus 5.5 --- ...ve-content-in-place-on-header-resize.patch | 316 ++++++++++++++++++ ....2+007+preserve-native-header-anchor.patch | 64 ---- patches/@legendapp/list/details.md | 9 + 3 files changed, 325 insertions(+), 64 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch new file mode 100644 index 000000000000..db08e34de5ed --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch @@ -0,0 +1,316 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 3ccffa79110..56382a16e9d 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -549,6 +549,7 @@ function cancelImperativeScroll(state) { + } + + // src/core/clampScrollOffset.ts ++var ANDROID_SHRINK_CLAMP_EPSILON = 0.1; + function getContentInsetTop(ctx) { + var _a3, _b, _c, _d; + const state = ctx.state; +@@ -561,6 +562,20 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ const state = ctx.state; ++ if (Platform.OS !== "android") { ++ return positionDiff; ++ } ++ const maxScroll = getScrollRangeEnd(ctx); ++ if (state.scroll <= maxScroll + ANDROID_SHRINK_CLAMP_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1999,14 +2014,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); +- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { +- const maxScroll = getScrollRangeEnd(ctx); +- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) { +- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); +- state.scroll = maxScroll; +- state.scrollPending = maxScroll; +- positionDiff = targetScroll - maxScroll; +- } ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); + } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { +@@ -6715,7 +6724,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + const sizeDiff = nextHeaderSize - previousHeaderSize; + const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; + const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; +- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; ++ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { + const { state } = ctx; +@@ -6726,7 +6735,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 676c117a45a..2c8dc675d44 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -528,6 +528,7 @@ function cancelImperativeScroll(state) { + } + + // src/core/clampScrollOffset.ts ++var ANDROID_SHRINK_CLAMP_EPSILON = 0.1; + function getContentInsetTop(ctx) { + var _a3, _b, _c, _d; + const state = ctx.state; +@@ -540,6 +541,20 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ const state = ctx.state; ++ if (Platform.OS !== "android") { ++ return positionDiff; ++ } ++ const maxScroll = getScrollRangeEnd(ctx); ++ if (state.scroll <= maxScroll + ANDROID_SHRINK_CLAMP_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1978,14 +1993,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); +- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { +- const maxScroll = getScrollRangeEnd(ctx); +- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) { +- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); +- state.scroll = maxScroll; +- state.scrollPending = maxScroll; +- positionDiff = targetScroll - maxScroll; +- } ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); + } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { +@@ -6694,7 +6703,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + const sizeDiff = nextHeaderSize - previousHeaderSize; + const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; + const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; +- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; ++ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { + const { state } = ctx; +@@ -6705,7 +6714,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index b6f2da18e38..684b3017523 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -488,8 +488,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -499,6 +497,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1887,6 +1891,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7587,7 +7594,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 7413734a88e..5c99c97c4d9 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -467,8 +467,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -478,6 +476,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1866,6 +1870,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7566,7 +7573,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index b6f2da18e38..684b3017523 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -488,8 +488,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -499,6 +497,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1887,6 +1891,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7587,7 +7594,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 7413734a88e..5c99c97c4d9 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -467,8 +467,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -478,6 +476,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1866,6 +1870,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7566,7 +7573,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch deleted file mode 100644 index e23ee197cf61..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+007+preserve-native-header-anchor.patch +++ /dev/null @@ -1,64 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index 134135f..ef5db5d 100644 ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -6705,7 +6705,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize - const sizeDiff = nextHeaderSize - previousHeaderSize; - const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; - const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; -- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; -+ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; - } - function setHeaderSize(ctx, size) { - const { state } = ctx; -@@ -6716,7 +6716,17 @@ function setHeaderSize(ctx, size) { - set$(ctx, "headerSize", size); - updateContentMetricsState(ctx); - if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { -- requestAdjust(ctx, size - previousHeaderSize); -+ let positionDiff = size - previousHeaderSize; -+ if (Platform.OS === "android") { -+ const maxScroll = getScrollRangeEnd(ctx); -+ if (state.scroll > maxScroll + SCROLL_ADJUST_EPSILON) { -+ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); -+ state.scroll = maxScroll; -+ state.scrollPending = maxScroll; -+ positionDiff = targetScroll - maxScroll; -+ } -+ } -+ requestAdjust(ctx, positionDiff); - } - } - state.didMeasureHeader = true; -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index cb06128..4c6d3d6 100644 ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -6684,7 +6684,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize - const sizeDiff = nextHeaderSize - previousHeaderSize; - const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; - const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; -- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; -+ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; - } - function setHeaderSize(ctx, size) { - const { state } = ctx; -@@ -6695,7 +6695,17 @@ function setHeaderSize(ctx, size) { - set$(ctx, "headerSize", size); - updateContentMetricsState(ctx); - if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { -- requestAdjust(ctx, size - previousHeaderSize); -+ let positionDiff = size - previousHeaderSize; -+ if (Platform.OS === "android") { -+ const maxScroll = getScrollRangeEnd(ctx); -+ if (state.scroll > maxScroll + SCROLL_ADJUST_EPSILON) { -+ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); -+ state.scroll = maxScroll; -+ state.scrollPending = maxScroll; -+ positionDiff = targetScroll - maxScroll; -+ } -+ } -+ requestAdjust(ctx, positionDiff); - } - } - state.didMeasureHeader = true; diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 551f199e10b3..85637bd6e55d 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -55,3 +55,12 @@ Each patch is the build output of its upstream PR, so it changes exactly what th - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On Android, clear the cache and open a chat from an LHN row with a Fix action, so it opens at the linked expense. The highlighted expense should come to rest a little below the top of the list and stay there. + +### [@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch](@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch) + +- Reason: LegendList keeps the visible items in place when the list header resizes above the viewport, but only on web. While offline, a chat shows a loading placeholder as the list header, and when it disappears on reconnect, the messages on screen moved by its height on iOS and Android. This patch makes header size changes above the viewport keep the visible items in place on every platform. On Android, it first accounts for the scroll offset Android clamps when the content shrinks, as LegendList already does for item size changes. +- Scope: All bundles, and only header size changes above the viewport. Web behaves as before. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/607 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On iOS or Android, open a chat with more history than the first page. Turn off the network connection. Turn the network connection back on. The messages on screen should not move when the placeholder above them disappears. From dc9fcd549005975d0a06e16cd23b5b996b61c58b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Nov 2026 09:56:13 +0100 Subject: [PATCH 108/109] fix: preserve cached chat scroll position on reconnect Keep displayed offline actions ready while queued app and report loads finish. This prevents loading skeletons and the first successful response from recreating the list and resetting its viewport. Co-authored-by: Codex --- src/hooks/useReportActionsListModel.ts | 5 +- src/pages/inbox/ChatReportActions.tsx | 8 +- .../inbox/report/ReportActionsListContext.tsx | 4 +- tests/ui/ChatReportActionsTest.tsx | 85 +++++++++++++++++++ tests/ui/ReportActionsListTest.tsx | 38 +++++++++ tests/unit/ReportActionsListContextTest.ts | 18 ++++ 6 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 tests/ui/ChatReportActionsTest.tsx diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 932f5f5d0af9..6dd1e09790e6 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -13,6 +13,7 @@ import {useRoute} from '@react-navigation/native'; import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; +import useInitial from './useInitial'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; @@ -57,7 +58,9 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, { selector: reportActionsListLoadingStateSelector, }); - const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; + // Cached actions displayed offline must stay mounted when queued loads resume. + const hasUsableReportActions = reportLoadingState?.hasOnceLoadedReportActions || (isOffline && reportActions.length > 0); + const hasOnceLoadedReportActions = useInitial(hasUsableReportActions ? true : undefined); const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; diff --git a/src/pages/inbox/ChatReportActions.tsx b/src/pages/inbox/ChatReportActions.tsx index 2c98a0405ff4..989660c71e2d 100644 --- a/src/pages/inbox/ChatReportActions.tsx +++ b/src/pages/inbox/ChatReportActions.tsx @@ -1,5 +1,7 @@ -/** Keep chat effects unmounted during app loading, except for the Concierge welcome screen. */ +/** Keep chat effects unmounted during the initial app load, except for the Concierge welcome screen. */ + import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; +import useInitial from '@hooks/useInitial'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -24,8 +26,10 @@ function ChatReportActions({report}: ChatReportActionsProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isAppLoadPending = useIsAppLoadPending(); const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); + const shouldMountReportActions = isOffline || !isAppLoadPending || isConciergeMainDM; + const hasRenderedReportActions = useInitial(shouldMountReportActions ? true : undefined); - if (isAppLoadPending && !isOffline && !isConciergeMainDM) { + if (!hasRenderedReportActions) { return ( () => ({isOffline: mockIsOffline})); +jest.mock('@hooks/useInFlightRequests', () => ({useIsAppLoadPending: () => mockIsAppLoadPending})); +jest.mock('@hooks/useOnyx', () => () => [undefined, {status: 'loaded'}]); +jest.mock('@pages/inbox/report/UserTypingEventListener', () => () => null); +jest.mock('@pages/inbox/report/ReportActionsLoadingSkeleton', () => { + const {View} = jest.requireActual('react-native'); + return () => ; +}); +jest.mock('@pages/inbox/report/ReportActionsList', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual('react-native'); + function MockReportActionsList() { + reactModule.useEffect(() => { + mockListMount(); + return () => { + mockListUnmount(); + }; + }, []); + return ; + } + return MockReportActionsList; +}); + +const report: Report = {reportID: '1234', reportName: 'Cached chat'}; + +describe('ChatReportActions', () => { + beforeEach(() => { + mockIsOffline = false; + mockIsAppLoadPending = true; + mockListMount.mockClear(); + mockListUnmount.mockClear(); + }); + + it('keeps the offline list mounted when its queued app load resumes online', () => { + // Given cached messages are visible while OpenApp is queued offline. + mockIsOffline = true; + const view = render(); + expect(screen.getByTestId('report-actions')).toBeTruthy(); + + // When connectivity returns before the queued app load finishes. + mockIsOffline = false; + view.rerender(); + + // Then the same list keeps its viewport throughout the refresh. + expect(screen.queryByTestId('loading-skeleton')).toBeNull(); + expect(screen.getByTestId('report-actions')).toBeTruthy(); + expect(mockListMount).toHaveBeenCalledTimes(1); + expect(mockListUnmount).not.toHaveBeenCalled(); + }); + + it('gates the first online mount without hiding it during a later app refresh', () => { + // Given the first app load is still fetching data online. + const view = render(); + expect(screen.getByTestId('loading-skeleton')).toBeTruthy(); + expect(mockListMount).not.toHaveBeenCalled(); + + // When the initial load finishes and a later refresh starts. + mockIsAppLoadPending = false; + view.rerender(); + expect(screen.getByTestId('report-actions')).toBeTruthy(); + mockIsAppLoadPending = true; + view.rerender(); + + // Then initial loading stays protected while the displayed list survives subsequent refreshes. + expect(screen.queryByTestId('loading-skeleton')).toBeNull(); + expect(screen.getByTestId('report-actions')).toBeTruthy(); + expect(mockListMount).toHaveBeenCalledTimes(1); + expect(mockListUnmount).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 7edd82160e91..19fb288cb2da 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -32,8 +32,10 @@ import {useConciergeChatHistoryActions, useConciergeChatHistoryState} from '@pag import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; +import ReportActionsListContent from '@pages/inbox/report/ReportActionsList/ReportActionsListContent'; import ReportActionsListFloatingMessageCounter from '@pages/inbox/report/ReportActionsList/ReportActionsListFloatingMessageCounter'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; +import ReportActionsSkeletonGuard from '@pages/inbox/report/ReportActionsSkeletonGuard'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import * as ReportActionsNewActionLiveTail from '@pages/inbox/report/useReportActionsNewActionLiveTail'; import * as ReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; @@ -1236,6 +1238,42 @@ describe('ReportActionsList (body)', () => { }); describe('Skeleton Loading States', () => { + it('keeps cached offline actions visible while queued app and report loads resume', () => { + // Given cached actions are visible before the report has loaded during this app session. + let isOffline = true; + let isAppLoadPending = true; + let hasOnceLoadedReportActions = false; + mockUseNetwork.mockImplementation(() => ({isOffline})); + mockUseIsReportLoadPending.mockReturnValue(true); + mockUseOnyx.mockImplementation((key, options) => { + if (key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { + return [isAppLoadPending, {status: 'loaded'}]; + } + if (key.includes('reportLoadingState')) { + return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions), {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); + const content = ; + const view = render({content}); + const visibleActions = getCapturedVisibleActions(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + + // When reconnecting resumes both queued loads before either response arrives. + isOffline = false; + view.rerender({content}); + + // Then the displayed action window survives both the pending loads and their completion. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(getCapturedVisibleActions()).toEqual(visibleActions); + isAppLoadPending = false; + hasOnceLoadedReportActions = true; + mockUseIsReportLoadPending.mockReturnValue(false); + view.rerender({content}); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(getCapturedVisibleActions()).toEqual(visibleActions); + }); + it('derives a true report pending value for the initial skeleton decision', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUsePaginatedReportActions.mockReturnValue(defaultPaginatedReportActionsResult); diff --git a/tests/unit/ReportActionsListContextTest.ts b/tests/unit/ReportActionsListContextTest.ts index fe5b75acacca..60a9233a99d1 100644 --- a/tests/unit/ReportActionsListContextTest.ts +++ b/tests/unit/ReportActionsListContextTest.ts @@ -58,6 +58,24 @@ function createReadinessSignals(overrides: Partial { + it('keeps a displayed unread action visible while its report refreshes', () => { + // Given a cached unread action has already been displayed before reconnecting. + const signals = createReadinessSignals({ + report: unreadReport, + hasOnceLoadedReportActions: true, + reportActionsLength: 1, + isMissingReportActions: false, + isReportLoadPending: true, + isLoadingInitialReportActions: true, + }); + + // When OpenReport refreshes that report after reconnecting. + const state = computeReportActionsSkeletonState(signals); + + // Then the existing list keeps its viewport instead of remounting behind a skeleton. + expect(state.shouldShowLoadingSkeleton).toBe(false); + }); + it('shows the loading skeleton for a pending OpenReport matching this report', () => { const state = computeReportActionsSkeletonState( createReadinessSignals({ From fc48e8536100d2bd205c13a1a39b729c433316cf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 12 Nov 2026 14:43:49 +0100 Subject: [PATCH 109/109] fix: redraw Android scroll anchoring in the same frame Backport react/react-native#58187 so Android redraws the ScrollView after maintainVisibleContentPosition corrects its offset. This prevents a stale frame when messages above a linked report action finish measuring. Co-authored-by: Codex --- patches/react-native/details.md | 8 ++++++++ ...+050+fix-mvcp-one-frame-jump-android.patch | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 patches/react-native/react-native+0.86.0+050+fix-mvcp-one-frame-jump-android.patch diff --git a/patches/react-native/details.md b/patches/react-native/details.md index e68326aa8957..ef2c6310e325 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -407,3 +407,11 @@ - E/App issue: https://github.com/Expensify/App/issues/100303 - PR introducing patch: this PR. - 0.86.0 migration note: **drop this patch on the RN 0.88 upgrade** — the unconditional `_firstVisibleView.tag` and `superview` guards are present in `0.88-stable` and absent from `v0.87.1`, which still has only the `enableViewCulling()`-gated check. + +### [react-native+0.86.0+050+fix-mvcp-one-frame-jump-android.patch](react-native+0.86.0+050+fix-mvcp-one-frame-jump-android.patch) + +- Reason: Returning from a thread can briefly move the linked message when rows above it finish measuring. Android moves the rows and corrects the scroll offset in the same mount, but `View.scrollTo` schedules the scroll view redraw for the next frame. This backports the upstream fix by invalidating the scroll view after either horizontal or vertical anchoring, so the current frame uses the corrected offset. +- Upstream PR/issue: https://github.com/react/react-native/pull/58187, fixes https://github.com/react/react-native/issues/58186. +- E/App issue: Tracked in https://github.com/Expensify/App/pull/100733. +- PR introducing patch: https://github.com/Expensify/App/pull/100733. +- 0.86.0 migration note: Remove once the adopted React Native release includes the upstream fix. diff --git a/patches/react-native/react-native+0.86.0+050+fix-mvcp-one-frame-jump-android.patch b/patches/react-native/react-native+0.86.0+050+fix-mvcp-one-frame-jump-android.patch new file mode 100644 index 000000000000..248207eac46a --- /dev/null +++ b/patches/react-native/react-native+0.86.0+050+fix-mvcp-one-frame-jump-android.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt +index d4c4bb9..1d75f58 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt +@@ -98,6 +98,7 @@ internal class MaintainVisibleScrollPositionHelper( + if (deltaX != 0) { + val scrollX = scrollView.scrollX + scrollView.scrollToPreservingMomentum(scrollX + deltaX, scrollView.scrollY) ++ scrollView.invalidate() + this.prevFirstVisibleFrame = newFrame + if (config.autoScrollToTopThreshold != null && scrollX <= config.autoScrollToTopThreshold) { + scrollView.reactSmoothScrollTo(0, scrollView.scrollY) +@@ -108,6 +109,7 @@ internal class MaintainVisibleScrollPositionHelper( + if (deltaY != 0) { + val scrollY = scrollView.scrollY + scrollView.scrollToPreservingMomentum(scrollView.scrollX, scrollY + deltaY) ++ scrollView.invalidate() + this.prevFirstVisibleFrame = newFrame + if (config.autoScrollToTopThreshold != null && scrollY <= config.autoScrollToTopThreshold) { + scrollView.reactSmoothScrollTo(scrollView.scrollX, 0)