From 7d4be685df226373baed9f0bae4386eddeb49079 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 13:49:59 +0200 Subject: [PATCH 1/6] 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 50b655e04837d37783dc4698766f24f8a9b84d00 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 21:16:25 +0200 Subject: [PATCH 2/6] feat: add chat pagination loading indicators --- src/hooks/useLoadReportActions.ts | 15 +- src/hooks/useReportActionsListModel.ts | 27 +- src/hooks/useReportActionsPaginationScroll.ts | 278 ++++++++++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 208 +++++++------ ...eportActionsPaginationLoadingIndicator.tsx | 48 +++ src/selectors/ReportMetaData.ts | 14 +- tests/ui/PaginationTest.tsx | 70 +++-- tests/ui/ReportActionsListTest.tsx | 249 +++++++++++++--- tests/unit/ReportActionsListThresholdTest.tsx | 7 +- ...tActionsPaginationLoadingIndicatorTest.tsx | 60 ++++ .../useReportActionsPaginationScrollTest.tsx | 267 +++++++++++++++++ 11 files changed, 1067 insertions(+), 176 deletions(-) create mode 100644 src/hooks/useReportActionsPaginationScroll.ts create mode 100644 src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx create mode 100644 tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx create mode 100644 tests/unit/useReportActionsPaginationScrollTest.tsx diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 140dd63b9ef5..04456142df61 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -76,6 +76,15 @@ function useLoadReportActions({ } } + const currentReportNewestActionID = newestFetchedReportActionID ?? currentReportNewestAction?.reportActionID; + const newestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportNewestActionID, transactionThreadNewestAction?.reportActionID]) + : (currentReportNewestActionID ?? newestReportAction?.reportActionID); + const oldestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportOldestAction?.reportActionID, transactionThreadOldestAction?.reportActionID]) + : currentReportOldestAction?.reportActionID; + const canLoadNewerChats = !!isFocused && !!newestReportAction && newestReportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + /** * Retrieves the next set of reportActions for the chat once we are nearing the end of what we are currently * displaying. @@ -102,7 +111,7 @@ function useLoadReportActions({ const loadNewerChats = (force = false) => { if ( !force && - (!isFocused || + (!canLoadNewerChats || !newestReportAction || !hasNewerActions || isOffline || @@ -138,6 +147,10 @@ function useLoadReportActions({ loadNewerChats, // The exact cursor `loadOlderChats` sends, which is not always the end of the rendered chain. currentReportOldestActionID: currentReportOldestAction?.reportActionID, + currentReportNewestActionID, + oldestReportActionsRequestCursor, + newestReportActionsRequestCursor, + canLoadNewerChats, }; } diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 67e39bdd688d..a7b0fc35fbab 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -58,6 +58,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; + const isLoadingNewerReportActions = reportLoadingState?.isLoadingNewerReportActions; + const hasLoadingNewerReportActionsError = reportLoadingState?.hasLoadingNewerReportActionsError; const {sessionStartTime, showFullHistory: conciergeShowFullHistory, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); const {setShowFullHistory: setConciergeShowFullHistory, setHadMessagesAtSessionStart: setConciergeHadMessagesAtSessionStart} = useConciergeSessionActions(); @@ -71,15 +73,16 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportPaginationState] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE}${reportID}`); - const {loadOlderChats, loadNewerChats, currentReportOldestActionID} = useLoadReportActions({ - reportID, - reportActions, - allReportActionIDs, - transactionThreadReportID, - hasOlderActions, - hasNewerActions, - newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, - }); + const {loadOlderChats, loadNewerChats, currentReportOldestActionID, currentReportNewestActionID, oldestReportActionsRequestCursor, newestReportActionsRequestCursor, canLoadNewerChats} = + useLoadReportActions({ + reportID, + reportActions, + allReportActionIDs, + transactionThreadReportID, + hasOlderActions, + hasNewerActions, + newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, + }); const { sortedReportActions, @@ -150,7 +153,13 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea hasNewerActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, oldestReportActionID: currentReportOldestActionID, + newestReportActionID: currentReportNewestActionID, + olderReportActionsRequestCursor: oldestReportActionsRequestCursor, + newerReportActionsRequestCursor: newestReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/hooks/useReportActionsPaginationScroll.ts b/src/hooks/useReportActionsPaginationScroll.ts new file mode 100644 index 000000000000..97f0f4437f4d --- /dev/null +++ b/src/hooks/useReportActionsPaginationScroll.ts @@ -0,0 +1,278 @@ +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import type {RefObject} from 'react'; + +import {useEffect, useEffectEvent, useLayoutEffect, useRef} from 'react'; + +const REPORT_ACTIONS_PAGINATION_THRESHOLD = 0.25; + +type PaginationGeometry = { + scroll: number; + scrollLength: number; + contentLength: number; +}; + +type PaginationListRef = { + getState: () => PaginationGeometry | undefined; +}; + +type ReportActionsPaginationDistances = { + older: number; + newer: number; +}; + +type UseReportActionsPaginationScrollArguments = { + reportID: string; + linkedReportActionID: string | undefined; + listRef: RefObject; + viewportHeight: number; + olderPaginationExtent: number; + newerPaginationExtent: number; + olderCursor: string | undefined; + newerCursor: string | undefined; + hasOlderActions: boolean; + hasNewerActions: boolean; + isLoadingOlderReportActions: boolean; + isLoadingNewerReportActions: boolean; + hasLoadingOlderReportActionsError: boolean; + hasLoadingNewerReportActionsError: boolean; + isOffline: boolean; + canLoadOlder: boolean; + canLoadNewer: boolean; + loadOlderActions: () => void; + loadNewerActions: () => void; +}; + +function useReportActionsPaginationScroll(options: UseReportActionsPaginationScrollArguments) { + const { + reportID, + linkedReportActionID, + olderCursor, + newerCursor, + isLoadingOlderReportActions, + isLoadingNewerReportActions, + isOffline, + canLoadOlder, + canLoadNewer, + viewportHeight, + olderPaginationExtent, + newerPaginationExtent, + hasOlderActions, + hasNewerActions, + } = options; + const latestArgumentsRef = useRef(options); + const lastRequestedOlderCursorRef = useRef(undefined); + const lastRequestedNewerCursorRef = useRef(undefined); + const wasNearOlderBoundaryRef = useRef(false); + const wasNearNewerBoundaryRef = useRef(false); + const wasOfflineRef = useRef(isOffline); + const couldLoadOlderRef = useRef(canLoadOlder); + const couldLoadNewerRef = useRef(canLoadNewer); + const wasLoadingOlderRef = useRef(isLoadingOlderReportActions); + const wasLoadingNewerRef = useRef(isLoadingNewerReportActions); + const paginationWindowGenerationRef = useRef(0); + const scheduledFramesRef = useRef(new Set()); + + useLayoutEffect(() => { + latestArgumentsRef.current = options; + }); + + const scheduleFrame = (callback: () => void, generation?: number) => { + const expectedGeneration = generation ?? paginationWindowGenerationRef.current; + const frame = requestAnimationFrame(() => { + scheduledFramesRef.current.delete(frame); + if (expectedGeneration === paginationWindowGenerationRef.current) { + callback(); + } + }); + scheduledFramesRef.current.add(frame); + }; + + const cancelScheduledFrames = () => { + for (const frame of scheduledFramesRef.current) { + cancelAnimationFrame(frame); + } + scheduledFramesRef.current.clear(); + }; + + const requestNewerActions = (cursor: string, canRetryError: boolean) => { + const runRequest = () => { + const latestArguments = latestArgumentsRef.current; + if ( + latestArguments.newerCursor !== cursor || + lastRequestedNewerCursorRef.current !== cursor || + !latestArguments.canLoadNewer || + !latestArguments.hasNewerActions || + latestArguments.isOffline || + latestArguments.isLoadingNewerReportActions || + (latestArguments.hasLoadingNewerReportActionsError && !canRetryError) + ) { + return; + } + latestArguments.loadNewerActions(); + }; + + if (!isSearchTopmostFullScreenRoute()) { + runRequest(); + return; + } + + const generation = paginationWindowGenerationRef.current; + TransitionTracker.runAfterTransitions({callback: () => scheduleFrame(runRequest, generation)}); + }; + + const checkPaginationBoundaries = () => { + const latestArguments = latestArgumentsRef.current; + const listState = latestArguments.listRef.current?.getState(); + if (!listState || latestArguments.viewportHeight <= 0) { + return; + } + + const distances = getReportActionsPaginationDistances(listState, latestArguments.olderPaginationExtent, latestArguments.newerPaginationExtent); + const threshold = latestArguments.viewportHeight * REPORT_ACTIONS_PAGINATION_THRESHOLD; + const isNearOlderBoundary = distances.older <= threshold; + const isNearNewerBoundary = distances.newer <= threshold; + if (!isNearOlderBoundary) { + lastRequestedOlderCursorRef.current = undefined; + } + if (!isNearNewerBoundary) { + lastRequestedNewerCursorRef.current = undefined; + } + + const canRetryOlderError = !wasNearOlderBoundaryRef.current; + const canRetryNewerError = !wasNearNewerBoundaryRef.current; + + if ( + isNearOlderBoundary && + latestArguments.canLoadOlder && + latestArguments.hasOlderActions && + !latestArguments.isOffline && + !latestArguments.isLoadingOlderReportActions && + (!latestArguments.hasLoadingOlderReportActionsError || canRetryOlderError) && + latestArguments.olderCursor && + lastRequestedOlderCursorRef.current !== latestArguments.olderCursor + ) { + lastRequestedOlderCursorRef.current = latestArguments.olderCursor; + latestArguments.loadOlderActions(); + } + + if ( + isNearNewerBoundary && + latestArguments.canLoadNewer && + latestArguments.hasNewerActions && + !latestArguments.isOffline && + !latestArguments.isLoadingNewerReportActions && + (!latestArguments.hasLoadingNewerReportActionsError || canRetryNewerError) && + latestArguments.newerCursor && + lastRequestedNewerCursorRef.current !== latestArguments.newerCursor + ) { + lastRequestedNewerCursorRef.current = latestArguments.newerCursor; + requestNewerActions(latestArguments.newerCursor, canRetryNewerError); + } + + wasNearOlderBoundaryRef.current = isNearOlderBoundary; + wasNearNewerBoundaryRef.current = isNearNewerBoundary; + }; + + const schedulePaginationBoundaryCheck = () => scheduleFrame(checkPaginationBoundaries); + const cancelScheduledFramesEffect = useEffectEvent(cancelScheduledFrames); + const schedulePaginationBoundaryCheckEffect = useEffectEvent(schedulePaginationBoundaryCheck); + + useEffect(() => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [reportID, linkedReportActionID]); + + useEffect(() => { + lastRequestedOlderCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [olderCursor]); + + useEffect(() => { + lastRequestedNewerCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [newerCursor]); + + useEffect(() => { + const finishedLoadingOlder = wasLoadingOlderRef.current && !isLoadingOlderReportActions; + const finishedLoadingNewer = wasLoadingNewerRef.current && !isLoadingNewerReportActions; + wasLoadingOlderRef.current = isLoadingOlderReportActions; + wasLoadingNewerRef.current = isLoadingNewerReportActions; + if (finishedLoadingOlder || finishedLoadingNewer) { + schedulePaginationBoundaryCheckEffect(); + } + }, [isLoadingOlderReportActions, isLoadingNewerReportActions]); + + useEffect(() => { + const didReconnect = wasOfflineRef.current && !isOffline; + wasOfflineRef.current = isOffline; + if (!didReconnect) { + return; + } + + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [isOffline]); + + useEffect(() => { + const canNowLoadOlder = !couldLoadOlderRef.current && canLoadOlder; + couldLoadOlderRef.current = canLoadOlder; + if (!canNowLoadOlder) { + return; + } + lastRequestedOlderCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadOlder]); + + useEffect(() => { + const canNowLoadNewer = !couldLoadNewerRef.current && canLoadNewer; + couldLoadNewerRef.current = canLoadNewer; + if (!canNowLoadNewer) { + return; + } + lastRequestedNewerCursorRef.current = undefined; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadNewer]); + + useEffect(() => { + schedulePaginationBoundaryCheckEffect(); + }, [viewportHeight, olderPaginationExtent, newerPaginationExtent, hasOlderActions, hasNewerActions]); + + useEffect( + () => () => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + }, + [], + ); + + return { + onScroll: checkPaginationBoundaries, + onContentSizeChange: schedulePaginationBoundaryCheck, + }; +} + +function getReportActionsPaginationDistances( + {scroll, scrollLength, contentLength}: PaginationGeometry, + olderPaginationExtent: number, + newerPaginationExtent: number, +): ReportActionsPaginationDistances { + return { + older: scroll - olderPaginationExtent, + newer: contentLength - scrollLength - scroll - newerPaginationExtent, + }; +} + +export default useReportActionsPaginationScroll; +export {REPORT_ACTIONS_PAGINATION_THRESHOLD}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 51d6101678d5..c88c7e2c650e 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -9,6 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useReportActionsPaginationScroll from '@hooks/useReportActionsPaginationScroll'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -16,9 +17,7 @@ import useUnreadMarker from '@hooks/useUnreadMarker'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import { getFirstVisibleReportActionID, getReportActionHtml, @@ -71,6 +70,7 @@ import {useReportActionsListActions, useReportActionsListState} from './ReportAc import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import ReportActionsListPaddingView from './ReportActionsListPaddingView'; +import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from './ReportActionsPaginationLoadingIndicator'; import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useFollowActionBadgeTarget from './useFollowActionBadgeTarget'; @@ -85,7 +85,6 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; -const PAGINATION_THRESHOLD = 0.75; const REPORT_ACTIONS_DRAW_DISTANCE = 1500; const REPORT_ACTION_COMMENT_SIZE = { @@ -160,7 +159,13 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct hasNewerActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, oldestReportActionID, + newestReportActionID, + olderReportActionsRequestCursor, + newerReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, @@ -182,21 +187,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const sessionStartTime = useConciergeSessionStartTime(); const didLayout = useRef(false); - const lastRequestedOldestActionIDRef = useRef(undefined); const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); useEffect(() => { didLayout.current = false; - lastRequestedOldestActionIDRef.current = undefined; }, [reportID]); - useEffect(() => { - if (isLoadingOlderReportActions && !hasLoadingOlderReportActionsError) { - return; - } - lastRequestedOldestActionIDRef.current = undefined; - }, [isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); - useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // OpenReport can first provide a tiny cached page and then replace it with the hydrated page. Remounting @@ -237,6 +233,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {getScrollOffset} = useActionListContext(); const listRef = useActionListRef(); const legendListRef = useRef(null); + const [viewportHeight, setViewportHeight] = useState(0); + const [newerFooterHeight, setNewerFooterHeight] = useState(0); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); useImperativeHandle( listRef, @@ -260,6 +259,34 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; const onShowPreviousMessages = handleShowPreviousMessages; + const canPaginateOlder = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasOlderActions && !showHiddenHistory; + const canPaginateNewer = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasNewerActions; + const shouldShowOlderPaginationLoadingIndicator = canPaginateOlder && !!isLoadingOlderReportActions && !hasLoadingOlderReportActionsError; + const shouldShowNewerPaginationLoadingIndicator = canPaginateNewer && !!isLoadingNewerReportActions && !hasLoadingNewerReportActionsError; + const olderPaginationExtent = shouldShowOlderPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0; + const newerPaginationExtent = canPaginateNewer ? newerFooterHeight + (shouldShowNewerPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0) : 0; + + const {onScroll: checkPaginationOnScroll, onContentSizeChange: checkPaginationOnContentSizeChange} = useReportActionsPaginationScroll({ + reportID, + linkedReportActionID: reportActionIDFromRoute, + listRef: legendListRef, + viewportHeight, + olderPaginationExtent, + newerPaginationExtent, + olderCursor: olderReportActionsRequestCursor ?? oldestReportActionID, + newerCursor: newerReportActionsRequestCursor ?? newestReportActionID, + hasOlderActions, + hasNewerActions, + isLoadingOlderReportActions: !!isLoadingOlderReportActions, + isLoadingNewerReportActions: !!isLoadingNewerReportActions, + hasLoadingOlderReportActionsError: !!hasLoadingOlderReportActionsError, + hasLoadingNewerReportActionsError: !!hasLoadingNewerReportActionsError, + isOffline, + canLoadOlder: !showHiddenHistory && loadedInitialViewportListID === listID, + canLoadNewer: canLoadNewerChats && loadedInitialViewportListID === listID, + loadOlderActions: () => loadOlderChats(false), + loadNewerActions: () => loadNewerChats(false), + }); const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); @@ -389,7 +416,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setTreatAsNoPaginationAnchor, }); - const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); const handleListLoad = () => { @@ -397,25 +423,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setLoadedInitialViewportListID(listID); }; - const loadOlderChatsOnStartReached = () => { - if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { - return; - } - - lastRequestedOldestActionIDRef.current = oldestReportActionID; - loadOlderChats(false); - }; - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); - const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; - - if (isNearStart) { - loadOlderChatsOnStartReached(); - } else { - lastRequestedOldestActionIDRef.current = undefined; - } + checkPaginationOnScroll(); const bottomRelativeEvent = { ...event, @@ -430,19 +441,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct emitComposerScrollEvents(); }; - const loadNewerChatsAfterTransitions = () => { - if (!isSearchTopmostFullScreenRoute()) { - loadNewerChats(false); - return; - } - - TransitionTracker.runAfterTransitions({ - callback: () => { - requestAnimationFrame(() => loadNewerChats(false)); - }, - }); - }; - const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); useFollowActionBadgeTarget({ @@ -527,16 +525,34 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isDraftPendingCompletion, ]; - const listHeaderComponent = ( - + const handleViewportLayout = (event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + }; + + const handleNewerFooterLayout = (event: LayoutChangeEvent) => { + setNewerFooterHeight(event.nativeEvent.layout.height); + }; + + const newerListFooterComponent = ( + <> + + + + {shouldShowNewerPaginationLoadingIndicator && } + ); const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; + const olderListHeaderComponent = ( + <> + {shouldShowOlderPaginationLoadingIndicator && } + {shouldShowOfflineSkeleton && } + + ); const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy, @@ -580,49 +596,61 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct report={report} isReportArchived={isReportArchived} > - trackVerticalScrolling(undefined)} - /> - {shouldShowInitialViewportSkeleton && ( - + + {viewportHeight > 0 ? ( + { + trackVerticalScrolling(undefined); + checkPaginationOnContentSizeChange(); + }} + /> + ) : ( - - )} + )} + {viewportHeight > 0 && shouldShowInitialViewportSkeleton && ( + + + + )} + ); diff --git a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx new file mode 100644 index 000000000000..383564ba4ad7 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx @@ -0,0 +1,48 @@ +import ActivityIndicator from '@components/ActivityIndicator'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +type PaginationDirection = 'older' | 'newer'; + +type ReportActionsPaginationLoadingIndicatorProps = { + direction: PaginationDirection; +}; + +const PAGINATION_LOADING_INDICATOR_HEIGHT = 72; +const PAGINATION_LOADING_INDICATOR_TOP_PADDING = 24; +const PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING = 24; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + height: PAGINATION_LOADING_INDICATOR_HEIGHT, + justifyContent: 'center', + paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, + }, +}); + +function ReportActionsPaginationLoadingIndicator({direction}: ReportActionsPaginationLoadingIndicatorProps) { + const testID = `report-actions-pagination-${direction}`; + + return ( + + + + ); +} + +export default ReportActionsPaginationLoadingIndicator; +export {PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, PAGINATION_LOADING_INDICATOR_HEIGHT, PAGINATION_LOADING_INDICATOR_TOP_PADDING}; diff --git a/src/selectors/ReportMetaData.ts b/src/selectors/ReportMetaData.ts index 12e9d65eae98..8d95a2397768 100644 --- a/src/selectors/ReportMetaData.ts +++ b/src/selectors/ReportMetaData.ts @@ -18,13 +18,25 @@ const reportActionsLoadingStateSelector = (loadingState: OnyxEntry, -): Pick | undefined => +): + | Pick< + ReportLoadingState, + | 'hasOnceLoadedReportActions' + | 'isLoadingInitialReportActions' + | 'isLoadingOlderReportActions' + | 'hasLoadingOlderReportActionsError' + | 'isLoadingNewerReportActions' + | 'hasLoadingNewerReportActionsError' + > + | undefined => loadingState ? { hasOnceLoadedReportActions: loadingState.hasOnceLoadedReportActions, isLoadingInitialReportActions: loadingState.isLoadingInitialReportActions, isLoadingOlderReportActions: loadingState.isLoadingOlderReportActions, hasLoadingOlderReportActionsError: loadingState.hasLoadingOlderReportActionsError, + isLoadingNewerReportActions: loadingState.isLoadingNewerReportActions, + hasLoadingNewerReportActionsError: loadingState.hasLoadingNewerReportActionsError, } : undefined; diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index f393af39fb38..15af7615ab80 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -85,7 +85,18 @@ function triggerListLayout(reportID?: string) { persist: () => {}, }); - fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); + fireEvent(within(report).getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: { + layout: { + x: 0, + y: 0, + ...LIST_SIZE, + }, + }, + }); + + const reportActionsList = within(report).getByTestId('report-actions-list'); + fireEvent(reportActionsList, 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); } function getReportActions(reportID?: string) { @@ -173,26 +184,31 @@ function mockGetOlderActions(messageCount: number) { }, ] : [], - hasOlderActions: comments['1'] != null, + hasOlderActions: !comments['1'], }; }); } -function mockGetNewerActions(messageCount: number) { - fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => ({ - onyxData: - reportID === REPORT_ID - ? [ - { - onyxMethod: 'merge', - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, - // The API also returns the action that was requested with the reportActionID. - value: buildReportComments(messageCount + 1, reportActionID, true), - }, - ] - : [], - hasNewerActions: messageCount > 0, - })); +function mockGetNewerActions(...messageCounts: number[]) { + let callIndex = 0; + fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => { + const messageCount = messageCounts[Math.min(callIndex, messageCounts.length - 1)]; + callIndex += 1; + return { + onyxData: + reportID === REPORT_ID + ? [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + // The API also returns the action that was requested with the reportActionID. + value: buildReportComments(messageCount + 1, reportActionID, true), + }, + ] + : [], + hasNewerActions: messageCount > 0, + }; + }); } async function fastSignInWithTestUser() { @@ -360,7 +376,7 @@ describe('Pagination', () => { it('opens a chat and load newer messages', async () => { mockOpenReport(5, '5'); - mockGetNewerActions(5); + mockGetNewerActions(5, 0); await signInAndGetApp(); await navigateToSidebarOption(COMMENT_LINKING_REPORT_ID); @@ -370,24 +386,18 @@ describe('Pagination', () => { await waitFor(() => { jest.requireMock('@react-navigation/native').triggerTransitionEnd(); }); - // Due to https://github.com/facebook/react-native/commit/3485e9ed871886b3e7408f90d623da5c018da493 - // we need to scroll too to trigger `onEndReached` which triggers other updates - scrollToOffset(LIST_END_OFFSET); // ReportScreen relies on the onLayout event to receive updates from onyx. triggerListLayout(); + scrollToOffset(LIST_END_OFFSET); + await waitForNetworkPromises(); + await waitForBatchedUpdatesWithAct(); + await waitFor(() => TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2)); await waitForNetworkPromises(); await waitForBatchedUpdatesWithAct(); - // Here we have 5 messages from the initial OpenReport and 5 from the initial GetNewerActions. + // The first newer page advances the cursor while the viewport remains at the boundary, so pagination + // requests the next page and stops when the backend reports that there are no more newer actions. expect(getReportActions()).toHaveLength(10); - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); - - // There is 1 extra call here because of the comment linking report. - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 1, {reportID: REPORT_ID, reportActionID: '5'}); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 4a0b2c90a543..3062803d5ca6 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {act, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -20,6 +20,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; +import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -85,9 +86,26 @@ const mockUseConciergeDraftActions = useConciergeDraftActions as jest.MockedFunc const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; -function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { +function getMockReportLoadingState( + selector: unknown, + hasOnceLoadedReportActions = true, + paginationState: { + isLoadingOlderReportActions?: boolean; + hasLoadingOlderReportActionsError?: boolean; + isLoadingNewerReportActions?: boolean; + hasLoadingNewerReportActionsError?: boolean; + } = {}, +) { return selector === reportActionsListLoadingStateSelector - ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false} + ? { + hasOnceLoadedReportActions, + isLoadingInitialReportActions: false, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, + ...paginationState, + } : undefined; } @@ -116,11 +134,13 @@ const defaultSidePanelState: ReturnType = { jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); const mockLoadOlderChats = jest.fn(); +const mockLoadNewerChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ loadOlderChats: mockLoadOlderChats, - loadNewerChats: jest.fn(), + loadNewerChats: mockLoadNewerChats, currentReportOldestActionID: reportActions.at(-1)?.reportActionID, + currentReportNewestActionID: reportActions.at(0)?.reportActionID, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -154,6 +174,8 @@ jest.mock('@legendapp/list/react-native', () => { }); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); +let mockInitialScrollIndex: number | undefined; +let mockInitialScrollIndexParams: {viewOffset?: number; viewPosition?: number} | undefined; jest.mock('@hooks/useReportActionsScroll', () => jest.fn(() => ({ listRef: {current: null}, @@ -164,8 +186,8 @@ jest.mock('@hooks/useReportActionsScroll', () => scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), shouldBeAlignedToTop: false, - initialScrollIndex: undefined, - initialScrollIndexParams: undefined, + initialScrollIndex: mockInitialScrollIndex, + initialScrollIndexParams: mockInitialScrollIndexParams, onLoad: jest.fn(), })), ); @@ -184,10 +206,16 @@ type MockLegendListProps = { extraData?: unknown; getItemType?: (item: OnyxTypes.ReportAction) => string; initialScrollAtEnd?: boolean; + initialScrollIndex?: {index: number; viewOffset?: number; viewPosition?: number}; + estimatedHeaderSize?: number; maintainScrollAtEnd?: {animated: boolean} | false; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + ListFooterComponentStyle?: unknown; onLoad?: () => void; + onContentSizeChange?: (width: number, height: number) => void; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -200,6 +228,8 @@ type MockLegendListProps = { }) => void; }; +type PaginationLoadingIndicatorProps = React.ComponentProps; + const {LegendList: mockLegendList} = jest.requireMock<{LegendList: jest.MockedFunction<(props: MockLegendListProps) => null>}>('@legendapp/list/react-native'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); @@ -207,6 +237,24 @@ const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/re const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); +function findPaginationLoadingIndicator(node: React.ReactNode): React.ReactElement | undefined { + if (!React.isValidElement<{children?: React.ReactNode}>(node)) { + return undefined; + } + if (node.type === ReportActionsPaginationLoadingIndicator) { + return node as React.ReactElement; + } + + for (const child of React.Children.toArray(node.props.children)) { + const loadingIndicator = findPaginationLoadingIndicator(child); + if (loadingIndicator) { + return loadingIndicator; + } + } + + return undefined; +} + const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAction, index = 0): {shouldDisableContextMenuForConciergeDraft?: boolean} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); @@ -230,6 +278,10 @@ const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); let mockHasOnceLoadedReportActions = true; +let mockIsLoadingOlderReportActions = false; +let mockHasLoadingOlderReportActionsError = false; +let mockIsLoadingNewerReportActions = false; +let mockHasLoadingNewerReportActionsError = false; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -287,12 +339,19 @@ const olderMockReportAction: OnyxTypes.ReportAction = { const renderReportActionsList = (props: {reportID?: string} = {}) => { const reportID = props.reportID ?? mockReport.reportID; - return render( + const view = render( , ); + const viewport = screen.queryByTestId('report-actions-list-viewport'); + if (viewport) { + fireEvent(viewport, 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + } + return view; }; describe('ReportActionsList (body)', () => { @@ -305,6 +364,12 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); mockHasOnceLoadedReportActions = true; + mockInitialScrollIndex = undefined; + mockInitialScrollIndexParams = undefined; + mockIsLoadingOlderReportActions = false; + mockHasLoadingOlderReportActionsError = false; + mockIsLoadingNewerReportActions = false; + mockHasLoadingNewerReportActionsError = false; mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); @@ -368,7 +433,15 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions), {status: 'loaded'}]; + return [ + getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, { + isLoadingOlderReportActions: mockIsLoadingOlderReportActions, + hasLoadingOlderReportActionsError: mockHasLoadingOlderReportActionsError, + isLoadingNewerReportActions: mockIsLoadingNewerReportActions, + hasLoadingNewerReportActionsError: mockHasLoadingNewerReportActionsError, + }), + {status: 'loaded'}, + ]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -582,52 +655,140 @@ describe('ReportActionsList (body)', () => { ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-link-preview-short`); }); - it('continues loading older pages from scroll events when LegendList does not report reaching the start', () => { - mockUseNetwork.mockReturnValue({isOffline: false}); - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: mockReportActions, - hasOlderActions: true, - }); - const view = renderReportActionsList(); + describe('pagination loading indicators', () => { + it('measures the chat viewport before mounting LegendList', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); - const listProps = getCapturedListProps(); - const createScrollEvent = (offset: number) => ({ - nativeEvent: { - contentOffset: {x: 0, y: offset}, - contentSize: {height: 1000, width: 300}, - layoutMeasurement: {height: 500, width: 300}, - }, + render( + , + ); + + expect(mockLegendList).not.toHaveBeenCalled(); + + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + + expect(mockLegendList).toHaveBeenCalled(); }); - act(() => { - listProps?.onScroll?.(createScrollEvent(0)); + it('shows padded loading indicators only while requests are active', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(0); + expect(getCapturedVisibleActions()).toEqual(mockReportActions.toReversed()); + + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)?.props.direction).toBe('older'); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(PAGINATION_LOADING_INDICATOR_HEIGHT); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + + mockHasLoadingOlderReportActionsError = true; + mockHasLoadingNewerReportActionsError = true; + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); }); - expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); - act(() => { - listProps?.onStartReached?.(); - listProps?.onScroll?.(createScrollEvent(0)); + it('keeps an unanchored newer window aligned to the end when idle', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.initialScrollIndex).toBeUndefined(); }); - expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [...mockReportActions, olderMockReportAction], - hasOlderActions: true, + it('preserves an explicit linked-action index when newer actions are available', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockInitialScrollIndex = 0; + mockInitialScrollIndexParams = {viewPosition: 0.5, viewOffset: 12}; + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollIndex).toEqual({index: 0, viewPosition: 0.5, viewOffset: 12}); }); - view.rerender( - , - ); - act(() => { - getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + it('removes an exhausted edge indicator and hides loading UI offline', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: false, + hasNewerActions: true, + }); + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + + mockUseNetwork.mockReturnValue({isOffline: true}); + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); }); - expect(mockLoadOlderChats).toHaveBeenCalledTimes(2); }); describe('Concierge Draft Context Menu', () => { diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index 61c2508361be..3da59cb457ff 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -1,4 +1,4 @@ -import {act, render, waitFor} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -128,6 +128,11 @@ async function renderList(initialOffset: number) { , ); + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: { + layout: {x: 0, y: 0, width: 300, height: 500}, + }, + }); await waitFor(() => expect(capturedListProps.maintainVisibleContentPosition).toBeDefined()); return utils; } diff --git a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx new file mode 100644 index 000000000000..d603ecdbf478 --- /dev/null +++ b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx @@ -0,0 +1,60 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsPaginationLoadingIndicator, { + PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + PAGINATION_LOADING_INDICATOR_HEIGHT, + PAGINATION_LOADING_INDICATOR_TOP_PADDING, +} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; + +import type {ComponentType} from 'react'; +import type {ViewProps} from 'react-native'; + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +jest.mock('@components/ActivityIndicator', () => ({ + __esModule: true, + default: ({testID}: {testID?: string}) => { + const {View: MockView} = jest.requireActual<{View: ComponentType}>('react-native'); + return ; + }, +})); + +const OLDER_TEST_ID = 'report-actions-pagination-older'; +const NEWER_TEST_ID = 'report-actions-pagination-newer'; + +describe('ReportActionsPaginationLoadingIndicator', () => { + it('renders only a spinner with generous vertical padding', () => { + const view = render(); + + expect(StyleSheet.flatten(screen.getByTestId(OLDER_TEST_ID, {includeHiddenElements: true}).props.style)).toEqual( + expect.objectContaining({ + alignItems: 'center', + height: PAGINATION_LOADING_INDICATOR_HEIGHT, + justifyContent: 'center', + paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, + }), + ); + expect(PAGINATION_LOADING_INDICATOR_TOP_PADDING).toBe(24); + expect(PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING).toBe(24); + expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + expect(screen.queryByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true})).toBeNull(); + + view.rerender(); + + expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + }); + + it('keeps pagination loading UI out of interaction and accessibility', () => { + render(); + + expect(screen.getByTestId(NEWER_TEST_ID, {includeHiddenElements: true}).props).toEqual( + expect.objectContaining({ + accessibilityElementsHidden: true, + importantForAccessibility: 'no-hide-descendants', + pointerEvents: 'none', + }), + ); + }); +}); diff --git a/tests/unit/useReportActionsPaginationScrollTest.tsx b/tests/unit/useReportActionsPaginationScrollTest.tsx new file mode 100644 index 000000000000..1e5dd91b4c30 --- /dev/null +++ b/tests/unit/useReportActionsPaginationScrollTest.tsx @@ -0,0 +1,267 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReportActionsPaginationScroll, {REPORT_ACTIONS_PAGINATION_THRESHOLD} from '@hooks/useReportActionsPaginationScroll'; + +const VIEWPORT_HEIGHT = 500; +const NEWER_PAGINATION_EXTENT = 168; +const OLDER_PAGINATION_EXTENT = 128; +const CONTENT_HEIGHT = 3000; +const BOUNDARY_DISTANCE = VIEWPORT_HEIGHT * 0.25; + +const mockLoadOlderActions = jest.fn(); +const mockLoadNewerActions = jest.fn(); +const mockAnimationFrames: FrameRequestCallback[] = []; +const mockTransitionCallbacks: Array<() => void> = []; +let mockIsSearchTopmostFullScreenRoute = false; + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute); +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + runAfterTransitions: ({callback}: {callback: () => void}) => { + mockTransitionCallbacks.push(callback); + return {cancel: jest.fn()}; + }, + }, +})); + +let listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, +}; + +// The hook reads only these three values from LegendList's much larger diagnostic state. +const listRef = { + current: { + getState: () => listMetrics, + }, +}; + +type HookParams = Parameters[0]; + +function buildParams(overrides: Partial = {}): HookParams { + return { + reportID: 'report-1', + linkedReportActionID: undefined, + listRef, + viewportHeight: VIEWPORT_HEIGHT, + olderPaginationExtent: OLDER_PAGINATION_EXTENT, + newerPaginationExtent: NEWER_PAGINATION_EXTENT, + olderCursor: 'older-1', + newerCursor: 'newer-1', + hasOlderActions: true, + hasNewerActions: true, + isLoadingOlderReportActions: false, + isLoadingNewerReportActions: false, + hasLoadingOlderReportActionsError: false, + hasLoadingNewerReportActionsError: false, + isOffline: false, + canLoadOlder: true, + canLoadNewer: true, + loadOlderActions: mockLoadOlderActions, + loadNewerActions: mockLoadNewerActions, + ...overrides, + }; +} + +function setListMetrics(offset: number, contentHeight = CONTENT_HEIGHT, viewportHeight = VIEWPORT_HEIGHT) { + listMetrics = { + contentLength: contentHeight, + scroll: offset, + scrollLength: viewportHeight, + }; +} + +function flushAnimationFrames() { + while (mockAnimationFrames.length > 0) { + act(() => { + for (const callback of mockAnimationFrames.splice(0)) { + callback(0); + } + }); + } +} + +function flushTransitions() { + act(() => { + for (const callback of mockTransitionCallbacks.splice(0)) { + callback(); + } + }); +} + +describe('useReportActionsPaginationScroll', () => { + beforeAll(() => { + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + mockAnimationFrames.push(callback); + return mockAnimationFrames.length; + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockAnimationFrames.length = 0; + mockTransitionCallbacks.length = 0; + mockIsSearchTopmostFullScreenRoute = false; + listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, + }; + }); + + it('triggers both directions at 25% of the real-message boundary after subtracting pagination extents', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + const olderBoundaryOffset = OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE; + const newerBoundaryOffset = CONTENT_HEIGHT - VIEWPORT_HEIGHT - NEWER_PAGINATION_EXTENT - BOUNDARY_DISTANCE; + + expect(REPORT_ACTIONS_PAGINATION_THRESHOLD).toBe(0.25); + + act(() => { + setListMetrics(olderBoundaryOffset + 1); + result.current.onScroll(); + setListMetrics(olderBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + setListMetrics(newerBoundaryOffset - 1); + result.current.onScroll(); + setListMetrics(newerBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('deduplicates repeated boundary events for the same actual request cursors', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + + act(() => { + setListMetrics(0); + result.current.onScroll(); + result.current.onScroll(); + setListMetrics(CONTENT_HEIGHT); + result.current.onScroll(); + result.current.onScroll(); + }); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('rechecks stationary geometry after a cursor advances and after content size settles', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, newerCursor: 'newer-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, newerCursor: 'newer-3'}); + act(() => { + result.current.onContentSizeChange(); + }); + listMetrics = {...listMetrics, contentLength: CONTENT_HEIGHT + 100}; + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); + + it('uses current availability when a scheduled content-size check runs', () => { + const initialParams = buildParams({hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(0); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + result.current.onContentSizeChange(); + }); + rerender({...initialParams, hasOlderActions: false, olderCursor: 'older-2'}); + flushAnimationFrames(); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('cancels delayed Search requests when the pagination window changes or unmounts', () => { + mockIsSearchTopmostFullScreenRoute = true; + const initialParams = buildParams({hasOlderActions: false}); + const firstView = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + firstView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + firstView.rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + + mockTransitionCallbacks.length = 0; + const secondView = renderHook(() => useReportActionsPaginationScroll(initialParams)); + act(() => { + secondView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + secondView.unmount(); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + }); + + it('blocks loading and stationary error loops but permits a deliberate leave and reentry retry', () => { + const loadingParams = buildParams({isLoadingOlderReportActions: true, hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: loadingParams}); + + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + const failedParams = {...loadingParams, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: true}; + rerender(failedParams); + flushAnimationFrames(); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + act(() => { + setListMetrics(OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE + 1); + result.current.onScroll(); + setListMetrics(0); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('resets request guards after reconnecting or changing the linked-action window', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, isOffline: true}); + rerender(initialParams); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); +}); From 8e909da2b95c2b94abc7df3ad896d07470a44961 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 11:53:49 +0200 Subject: [PATCH 3/6] test: provide chat viewport layout in unread indicator tests --- tests/ui/UnreadIndicatorsTest.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 84279c7cd873..f612d2cf68f5 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -115,7 +115,7 @@ function navigateToSidebar(): Promise { return waitForBatchedUpdates(); } -async function navigateToSidebarOptionWithoutAct(index: number): Promise { +async function navigateToSidebarOptionWithoutViewportLayout(index: number): Promise { const optionRow = screen.queryAllByAccessibilityHint(TestHelper.getNavigateToChatHintRegex()).at(index); if (!optionRow) { return; @@ -124,6 +124,16 @@ async function navigateToSidebarOptionWithoutAct(index: number): Promise { await waitForBatchedUpdates(); } +async function navigateToSidebarOptionWithoutAct(index: number): Promise { + await navigateToSidebarOptionWithoutViewportLayout(index); + + // React Native reports the viewport layout automatically, but View.onLayout does not fire in Jest. + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + await waitForBatchedUpdates(); +} + function areYouOnChatListScreen(): boolean { const hintText = TestHelper.translateLocal('sidebarScreen.listOfChats'); const sidebarLinks = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); @@ -837,7 +847,8 @@ describe('Unread Indicators', () => { }, }); - await navigateToSidebarOptionWithoutAct(0); + // The self-DM cannot open its report while offline, so it never mounts a report-actions viewport. + await navigateToSidebarOptionWithoutViewportLayout(0); const fakeTransaction = { ...createRandomTransaction(1), From 3c63623538a07ed261070c815616fe003f9ccf80 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 11 Sep 2026 12:58:07 +0200 Subject: [PATCH 4/6] refactor: remove compiled list memoization --- .../FlatList/FlatList/index.ios.tsx | 35 +++++++------------ .../useReportActionsNewActionLiveTail.ts | 6 ++-- 2 files changed, 16 insertions(+), 25 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/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 fbc37ca24013b469f2d8e6d499d17e2fa8346ad5 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 14:26:47 +0200 Subject: [PATCH 5/6] feat: migrate remaining FlashList consumers to LegendList --- jest.config.js | 2 +- jest/setup.ts | 1 - jest/setupAfterEnv.ts | 29 - package-lock.json | 12 - package.json | 1 - ...+fix-horizontal-height-normalization.patch | 51 -- ...st+2.3.0+002+skip-layout-when-hidden.patch | 49 -- ...fix-inverted-scroll-direction-on-web.patch | 191 ------ ...0+004+fix-inverted-first-item-offset.patch | 38 -- ...nding-children-blocking-measurements.patch | 68 -- ...+2.3.0+006+fix-inverted-mvcp-android.patch | 114 ---- ...007+fix-scroll-anchor-unmount-on-ios.patch | 80 --- ...lash-list+2.3.0+008+increase-timeout.patch | 13 - ...0+009+ignore-stale-viewholder-layout.patch | 29 - ...+2.3.0+010+fix-web-subpixel-rounding.patch | 16 - ...2.3.0+011+sort-for-natural-DOM-order.patch | 542 ---------------- ...+012+fix-scrollbar-oscillation-crash.patch | 126 ---- ....3.0+013+improve-scroll-key-handling.patch | 166 ----- ...-list+2.3.0+014+external-window-size.patch | 104 --- ...ash-list+2.3.0+015+mvcp-header-aware.patch | 358 ----------- ...gnore-stale-viewholder-render-layout.patch | 97 --- patches/@shopify/flash-list/details.md | 203 ------ .../EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 27 +- .../EmojiPickerMenu/index.native.tsx | 8 +- .../EmojiPicker/EmojiPickerMenu/index.tsx | 12 +- .../EmojiPickerMenu/useEmojiPickerMenu.ts | 6 +- src/components/FlashList/index.tsx | 27 - src/components/FlashList/types.ts | 3 - .../LHNOptionsList/LHNOptionsList.tsx | 92 ++- .../index.native.tsx | 24 - .../OptionRowRendererComponent/index.tsx | 3 - .../ExternalScrollFlashListTable.tsx | 248 -------- .../ExternalScrollLegendListTable.tsx | 280 +++++++++ .../MoneyRequestReportTransactionList.tsx | 29 +- .../MoneyRequestReportUnifiedList.tsx | 87 ++- .../v2/content/ScrollableContent.tsx | 2 +- .../MoneyRequestReportPreviewProvider.tsx | 4 +- .../TransactionReportCarousel.tsx | 6 +- .../MoneyRequestReportPreview/index.tsx | 4 +- .../MoneyRequestReportPreview/types.ts | 4 +- .../useReportPreviewCarousel.tsx | 14 +- .../ReportActionItemImages.tsx | 4 +- .../Search/ExpenseGroupedSearchView.tsx | 16 +- .../BaseSearchList/index.native.tsx | 13 +- .../SearchList/BaseSearchList/index.tsx | 47 +- .../Search/SearchList/BaseSearchList/types.ts | 12 +- .../Search/hooks/useSearchListViewState.ts | 4 +- .../Search/primitives/useScrollRestoration.ts | 6 +- .../SelectionList/BaseSelectionList.tsx | 19 +- .../BaseSelectionListWithSections.tsx | 26 +- .../hooks/useScrollToFocusedInput/types.ts | 4 +- .../hooks/useSelectionListKeyboardFocus.ts | 3 - .../hooks/useSelectionListScroll.ts | 12 +- src/components/Table/Table.tsx | 82 ++- src/components/Table/TableBody.tsx | 119 ++-- src/components/Table/TableContext.tsx | 12 +- src/components/Table/TableHeader.tsx | 2 +- src/components/Table/TableListHeader.tsx | 2 +- src/components/Table/buildTableListData.ts | 2 +- src/components/Table/index.tsx | 4 +- src/components/Table/tableAccessibility.ts | 15 +- src/components/Table/types.ts | 14 +- src/components/Tables/AgentsTable/index.tsx | 4 +- .../Tables/DomainAdminsTable/index.tsx | 4 +- .../Tables/DomainGroupsTable/index.tsx | 4 +- .../Tables/DomainListTable/index.tsx | 4 +- .../Tables/DomainMembersTable/index.tsx | 4 +- .../PersonalExpenseRulesTable/index.tsx | 4 +- .../Tables/ReportParticipantsTable/index.tsx | 4 +- .../Tables/RoomMembersTable/index.tsx | 4 +- .../Tables/WorkspaceCategoriesTable/index.tsx | 4 +- .../WorkspaceCategoryRulesTable/index.tsx | 4 +- .../WorkspaceCompanyCardsTable/index.tsx | 4 +- .../WorkspaceDistanceRatesTable/index.tsx | 4 +- .../WorkspaceExpenseDefaultsTable/index.tsx | 4 +- .../WorkspaceExpensifyCardsTable/index.tsx | 4 +- .../Tables/WorkspaceListTable/index.tsx | 4 +- .../Tables/WorkspaceMembersTable/index.tsx | 4 +- .../Tables/WorkspacePerDiemTable/index.tsx | 4 +- .../index.tsx | 4 +- .../Tables/WorkspaceRoomsTable/index.tsx | 6 +- .../Tables/WorkspaceSpendRulesTable/index.tsx | 4 +- .../Tables/WorkspaceTagsTable/index.tsx | 4 +- .../Tables/WorkspaceTaxesTable/index.tsx | 4 +- .../Tables/WorkspaceVendorsTable/index.tsx | 4 +- .../Tables/WorkspaceViewTagsTable/index.tsx | 4 +- .../Security/DeviceManagementPage.tsx | 11 +- .../settings/Wallet/PaymentMethodList.tsx | 5 +- .../fields/WorkspaceFieldsSection.tsx | 11 +- .../MerchantRules/PreviewMatchesPage.tsx | 9 +- .../MoneyRequestReportPreview.stories.tsx | 4 +- tests/perf-test/SelectionList.perf-test.tsx | 15 - tests/ui/IOURequestStepDistanceRateTest.tsx | 16 +- tests/ui/TableSelectionTest.tsx | 4 +- tests/ui/TableTest.tsx | 594 +++++++----------- tests/unit/ActionListContextProviderTest.tsx | 7 +- tests/unit/BaseSelectionListSectionsTest.tsx | 20 +- tests/unit/BaseSelectionListTest.tsx | 16 +- tests/unit/FlashListTest.tsx | 86 --- .../SearchSingleSelectionPickerTest.tsx | 14 +- tests/unit/SearchAutocompleteListTest.tsx | 31 +- ...alScrollLegendListTableIntegrationTest.tsx | 107 ++++ .../ExternalScrollLegendListTableTest.tsx | 110 ++++ .../useSelectionListKeyboardFocus.test.ts | 9 +- .../useSelectionListScroll.test.ts | 19 +- tests/unit/useReportScrollManagerTest.tsx | 9 +- 106 files changed, 1206 insertions(+), 3577 deletions(-) delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch delete mode 100644 patches/@shopify/flash-list/details.md delete mode 100644 src/components/FlashList/index.tsx delete mode 100644 src/components/FlashList/types.ts delete mode 100644 src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx delete mode 100644 src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx delete mode 100644 src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx create mode 100644 src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx delete mode 100644 tests/unit/FlashListTest.tsx create mode 100644 tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableIntegrationTest.tsx create mode 100644 tests/unit/components/MoneyRequestReportView/ExternalScrollLegendListTableTest.tsx diff --git a/jest.config.js b/jest.config.js index 14b6e257de98..c929d2249591 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,7 +25,7 @@ module.exports = { '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: [ - '/node_modules/(?!.*(react-native|expo|react-navigation|uuid|@shopify\/flash-list).*/)', + '/node_modules/(?!.*(react-native|expo|react-navigation|uuid).*/)', // Prevent Babel from transforming worklets in this file so they are treated as normal functions, otherwise FormatSelectionUtilsTest won't run. '/node_modules/@expensify/react-native-live-markdown/lib/commonjs/parseExpensiMark.js', ], diff --git a/jest/setup.ts b/jest/setup.ts index 98bd75c5a569..cfab21045fdb 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,6 +1,5 @@ import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; -import '@shopify/flash-list/jestSetup'; import type * as LegendListModule from '@legendapp/list/react-native'; import type {ReactNode} from 'react'; import type React from 'react'; diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index 49701c9c77bb..2bf9decad871 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -37,35 +37,6 @@ if (Keyboard && typeof Keyboard.addListener === 'function') { }) as typeof Keyboard.addListener; } -// This mock must live in setupAfterEnv (not setupFiles) because @shopify/flash-list/jestSetup, -// imported in setup.ts, registers its own measureLayout mock. Placing ours here ensures it -// runs after FlashList's setup and takes precedence. -jest.mock( - '@shopify/flash-list/dist/recyclerview/utils/measureLayout', - () => - ({ - ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), - measureParentSize: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureFirstChildLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 400, - })), - measureItemLayout: jest.fn().mockImplementation(() => ({ - x: 0, - y: 0, - width: 300, - height: 75, - })), - }) as Record, -); - // Auto-initialize Onyx for tests. // Tests that already call Onyx.init() in their own beforeAll will safely re-configure Onyx — // the second init() just re-runs initStoreValues and re-resolves the already-resolved deferred task. diff --git a/package-lock.json b/package-lock.json index ce722a5d0fe3..7c36bc4e14af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,6 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.47.0", "@sentry/react-native": "8.7.0", - "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", @@ -16964,17 +16963,6 @@ "webpack": ">=5.0.0" } }, - "node_modules/@shopify/flash-list": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.0.tgz", - "integrity": "sha512-DR7VuN8KJHTYj9zv1/IhpqrMBMQyeeW/DCWCbVQAAkWhHrc6ylIbXOY+qK93CuHABV+dNHXK/3V6p4wCSW/+wA==", - "license": "MIT", - "peerDependencies": { - "@babel/runtime": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/@shopify/react-native-skia": { "version": "2.4.18", "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.18.tgz", diff --git a/package.json b/package.json index d34a7230b0a2..deb448cf7a10 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,6 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.47.0", "@sentry/react-native": "8.7.0", - "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.18", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch deleted file mode 100644 index e9d9e3dfd981..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch +++ /dev/null @@ -1,51 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -index fb40ded..12375d9 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -@@ -92,6 +92,17 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { - */ - normalizeLayoutHeights(layoutInfo) { - var _a, _b; -+ // If the tallest item was removed from the list (e.g. item deletion), -+ // reset tracking and clear minHeight so items get re-measured naturally. -+ if (this.tallestItem && !this.layouts.includes(this.tallestItem)) { -+ for (const layout of this.layouts) { -+ layout.minHeight = 0; -+ } -+ this.tallestItem = undefined; -+ this.tallestItemHeight = 0; -+ this.requiresRepaint = true; -+ return; -+ } - let newTallestItem; - for (const info of layoutInfo) { - const { index } = info; -@@ -115,8 +126,26 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { - layout.minHeight = targetMinHeight; - } - newTallestItem.minHeight = 0; -- this.tallestItem = newTallestItem; -- this.tallestItemHeight = newTallestItem.height; -+ // When items shrink (targetMinHeight = 0), reset tracking so the -+ // next cycle re-detects the tallest item after repaint and properly -+ // re-applies minHeight for all layouts. -+ if (targetMinHeight === 0) { -+ this.tallestItem = undefined; -+ this.tallestItemHeight = 0; -+ } else { -+ this.tallestItem = newTallestItem; -+ this.tallestItemHeight = newTallestItem.height; -+ } -+ return; -+ } -+ // Normalize newly added items that haven't been assigned minHeight yet. -+ if (this.tallestItem) { -+ for (const layout of this.layouts) { -+ if (layout !== this.tallestItem && layout.minHeight !== this.tallestItemHeight) { -+ layout.minHeight = this.tallestItemHeight; -+ layout.height = this.tallestItemHeight; -+ } -+ } - } - } - /** diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch deleted file mode 100644 index eae8317799bf..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch +++ /dev/null @@ -1,49 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..dd2d3bc 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => { - if (internalViewRef.current && firstChildViewRef.current) { - // Measure the outer container size and inner container layout - const outerViewSize = measureParentSize(internalViewRef.current); -+ if (outerViewSize.width === 0 && outerViewSize.height === 0) { -+ containerViewSizeRef.current = outerViewSize; -+ return; -+ } - const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current); - containerViewSizeRef.current = outerViewSize; - // firstChildViewLayout is already relative to the outer container, -@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => { - if (pendingChildIds.size > 0) { - return; - } -+ if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && -+ ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { -+ return; -+ } - const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { - const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index)); - // comapre height with stored layout -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..9908674 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -165,7 +165,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current) { - // Adjust offset for RTL layouts in horizontal mode -- if (I18nManager.isRTL && horizontal) { -+ if (I18nManager.isRTL && horizontal && recyclerViewManager.hasLayout()) { - // eslint-disable-next-line no-param-reassign - offset = - adjustOffsetForRTL(offset, recyclerViewManager.getChildContainerDimensions().width, recyclerViewManager.getWindowSize().width) + -@@ -235,6 +235,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - * Returns a Promise that resolves when the scroll is complete. - */ - scrollToIndex: ({ index, animated, viewPosition, viewOffset, }) => { -+ if (!recyclerViewManager.hasLayout()) { -+ return Promise.resolve(); -+ } - return new Promise((resolve) => { - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current && diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch deleted file mode 100644 index edb436a356b4..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch +++ /dev/null @@ -1,191 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index dd2d3bc..d7a3d84 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -2,8 +2,8 @@ - * RecyclerView is a high-performance list component that efficiently renders and recycles list items. - * It's designed to handle large lists with optimal memory usage and smooth scrolling. - */ --import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; --import { Animated, I18nManager, } from "react-native"; -+import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; -+import { Animated, I18nManager, Platform, } from "react-native"; - import { ErrorMessages } from "../errors/ErrorMessages"; - import { WarningMessages } from "../errors/WarningMessages"; - import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; -@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => { - // Hook to detect when scrolling reaches list bounds - const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); - const isHorizontalRTL = I18nManager.isRTL && horizontal; -+ // Web-only: Fix inverted scroll direction. -+ useEffect(() => { -+ if (!inverted || Platform.OS !== "web") { -+ return; -+ } -+ const scrollRef = scrollViewRef.current; -+ if (!scrollRef || typeof scrollRef.getScrollableNode !== "function") { -+ return; -+ } -+ const node = scrollRef.getScrollableNode(); -+ if (!node) { -+ return; -+ } -+ const wheelHandler = (ev) => { -+ const target = ev.target; -+ const deltaX = ev.deltaX || ev.wheelDeltaX || 0; -+ const deltaY = ev.deltaY || ev.wheelDeltaY || 0; -+ // Compute scroll limits from the DOM node for overscroll recoil prevention. -+ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; -+ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; -+ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; -+ const isOnScrollLimit = nodeScrollOffset <= 0 || Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; -+ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; -+ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; -+ const clientLength = horizontal ? target.clientWidth : target.clientHeight; -+ const isEventTargetScrollable = scrollLength > clientLength; -+ const delta = horizontal ? deltaX : deltaY; -+ let leftoverDelta = delta; -+ if (isEventTargetScrollable) { -+ leftoverDelta = delta < 0 -+ ? Math.min(delta + scrollOffset, 0) -+ : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0); -+ } -+ const targetDelta = delta - leftoverDelta; -+ if (horizontal) { -+ if (Math.abs(deltaX) > Math.abs(deltaY)) { -+ target.scrollLeft += targetDelta; -+ node.scrollLeft = node.scrollLeft - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } -+ else { -+ // Prevent overscroll recoil/rubber band at scroll boundaries. -+ if (isOnScrollLimit && Math.abs(deltaY) > 0) { -+ ev.preventDefault(); -+ } -+ if (Math.abs(deltaY) > Math.abs(deltaX)) { -+ target.scrollTop += targetDelta; -+ node.scrollTop = node.scrollTop - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } -+ }; -+ node.addEventListener("wheel", wheelHandler, { passive: false }); -+ return () => { -+ node.removeEventListener("wheel", wheelHandler); -+ }; -+ }, [inverted, horizontal]); - /** - * Initialize the RecyclerView by measuring and setting up the window size - * This effect runs when the component mounts or when layout changes -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 34722d4..ea801d2 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -5,6 +5,7 @@ - import React, { - RefObject, - useCallback, -+ useEffect, - useLayoutEffect, - useMemo, - useRef, -@@ -17,6 +18,7 @@ import { - I18nManager, - NativeScrollEvent, - NativeSyntheticEvent, -+ Platform, - } from "react-native"; - - import { FlashListRef } from "../FlashListRef"; -@@ -158,6 +160,88 @@ const RecyclerViewComponent = ( - - const isHorizontalRTL = I18nManager.isRTL && horizontal; - -+ /** -+ * Web-only: Fix inverted scroll direction. -+ * When a list is visually inverted via scaleY/scaleX: -1, the browser's native -+ * wheel scroll goes in the wrong visual direction. This effect attaches a wheel -+ * event listener that negates the delta to correct the scroll direction. -+ * Mirrors the fix in react-native-web's VirtualizedList. -+ */ -+ useEffect(() => { -+ if (!inverted || Platform.OS !== "web") { -+ return; -+ } -+ const scrollRef = scrollViewRef.current; -+ if (!scrollRef || typeof (scrollRef as any).getScrollableNode !== "function") { -+ return; -+ } -+ const node = (scrollRef as any).getScrollableNode() as HTMLElement; -+ if (!node) { -+ return; -+ } -+ -+ const wheelHandler = (ev: WheelEvent) => { -+ const target = ev.target as HTMLElement; -+ const deltaX = ev.deltaX || (ev as any).wheelDeltaX || 0; -+ const deltaY = ev.deltaY || (ev as any).wheelDeltaY || 0; -+ -+ // Compute scroll limits from the DOM node for overscroll recoil prevention. -+ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; -+ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; -+ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; -+ const isOnScrollLimit = -+ nodeScrollOffset <= 0 || -+ Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; -+ -+ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; -+ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; -+ const clientLength = horizontal ? target.clientWidth : target.clientHeight; -+ const isEventTargetScrollable = scrollLength > clientLength; -+ const delta = horizontal ? deltaX : deltaY; -+ -+ // Calculate how much delta the event target can consume vs leftover for parent -+ let leftoverDelta = delta; -+ if (isEventTargetScrollable) { -+ leftoverDelta = -+ delta < 0 -+ ? Math.min(delta + scrollOffset, 0) -+ : Math.max( -+ delta - (scrollLength - clientLength - scrollOffset), -+ 0 -+ ); -+ } -+ const targetDelta = delta - leftoverDelta; -+ -+ // Only adjust scroll and consume the event when the dominant axis -+ // matches the list orientation. stopPropagation prevents parent -+ // inverted lists from also handling this event. -+ if (horizontal) { -+ if (Math.abs(deltaX) > Math.abs(deltaY)) { -+ target.scrollLeft += targetDelta; -+ node.scrollLeft = node.scrollLeft - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } else { -+ // Prevent overscroll recoil/rubber band at scroll boundaries. -+ if (isOnScrollLimit && Math.abs(deltaY) > 0) { -+ ev.preventDefault(); -+ } -+ if (Math.abs(deltaY) > Math.abs(deltaX)) { -+ target.scrollTop += targetDelta; -+ node.scrollTop = node.scrollTop - leftoverDelta; -+ ev.preventDefault(); -+ ev.stopPropagation(); -+ } -+ } -+ }; -+ -+ node.addEventListener("wheel", wheelHandler, { passive: false }); -+ return () => { -+ node.removeEventListener("wheel", wheelHandler); -+ }; -+ }, [inverted, horizontal]); -+ - /** - * Initialize the RecyclerView by measuring and setting up the window size - * This effect runs when the component mounts or when layout changes diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch deleted file mode 100644 index 98bac124be64..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch +++ /dev/null @@ -1,38 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index d7a3d84..ffcdad8 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => { - containerViewSizeRef.current = outerViewSize; - // firstChildViewLayout is already relative to the outer container, - // so its x/y directly gives the first item offset. -- const firstItemOffset = horizontal -- ? firstChildViewLayout.x -- : firstChildViewLayout.y; -+ const firstItemOffset = inverted -+ ? 0 -+ : horizontal -+ ? firstChildViewLayout.x -+ : firstChildViewLayout.y; - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams({ - width: horizontal ? outerViewSize.width : firstChildViewLayout.width, -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index ea801d2..8a7deff 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -259,9 +259,11 @@ const RecyclerViewComponent = ( - - // firstChildViewLayout is already relative to the outer container, - // so its x/y directly gives the first item offset. -- const firstItemOffset = horizontal -- ? firstChildViewLayout.x -- : firstChildViewLayout.y; -+ const firstItemOffset = inverted -+ ? 0 -+ : horizontal -+ ? firstChildViewLayout.x -+ : firstChildViewLayout.y; - - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams( diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch deleted file mode 100644 index 6b96845c5875..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch +++ /dev/null @@ -1,68 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ffcdad8..ee42f63 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(() => { - var _a, _b; -- if (pendingChildIds.size > 0) { -- return; -- } - if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && - ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { - return; -@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => { - } - if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && - !hasExceededMaxRendersWithoutCommit) { -- // Trigger re-render if layout modifications were made -- setRenderId((prev) => prev + 1); -+ if (pendingChildIds.size > 0) { -+ // When child FlashLists are still loading, avoid triggering a full -+ // RecyclerView re-render (setRenderId) to prevent cascading setState -+ // calls that could cause "Maximum update depth exceeded" errors. -+ // Instead, just commit the layout to update item positions in -+ // ViewHolderCollection without re-measuring. -+ (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); -+ } else { -+ // Trigger re-render if layout modifications were made -+ setRenderId((prev) => prev + 1); -+ } - } - else { - (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 8a7deff..b2bd67a 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -287,9 +287,6 @@ const RecyclerViewComponent = ( - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(() => { -- if (pendingChildIds.size > 0) { -- return; -- } - const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { - const layout = measureItemLayout( - viewHolderRef.current!, -@@ -323,8 +320,17 @@ const RecyclerViewComponent = ( - recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && - !hasExceededMaxRendersWithoutCommit - ) { -- // Trigger re-render if layout modifications were made -- setRenderId((prev) => prev + 1); -+ if (pendingChildIds.size > 0) { -+ // When child FlashLists are still loading, avoid triggering a full -+ // RecyclerView re-render (setRenderId) to prevent cascading setState -+ // calls that could cause "Maximum update depth exceeded" errors. -+ // Instead, just commit the layout to update item positions in -+ // ViewHolderCollection without re-measuring. -+ viewHolderCollectionRef.current?.commitLayout(); -+ } else { -+ // Trigger re-render if layout modifications were made -+ setRenderId((prev) => prev + 1); -+ } - } else { - viewHolderCollectionRef.current?.commitLayout(); - applyOffsetCorrection(); diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch deleted file mode 100644 index 48a9b893bbfd..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch +++ /dev/null @@ -1,114 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..52546f7 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -1,5 +1,5 @@ - import { useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react"; --import { I18nManager } from "react-native"; -+import { I18nManager, Platform } from "react-native"; - import { adjustOffsetForRTL } from "../utils/adjustOffsetForRTL"; - import { PlatformConfig } from "../../native/config/PlatformHelper"; - import { WarningMessages } from "../../errors/WarningMessages"; -@@ -25,6 +25,8 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const isUnmounted = useUnmountFlag(); - const [_, setRenderId] = useState(0); - const pauseOffsetCorrection = useRef(false); -+ const pendingAndroidInvertedRafId = useRef(null); -+ const skipNextAndroidInvertedCorrection = useRef(false); - const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); - const { setTimeout } = useUnmountAwareTimeout(); - // Track the first visible item for maintaining scroll position -@@ -79,7 +81,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - */ - const applyOffsetCorrection = useCallback(() => { - var _a, _b, _c; -- const { horizontal, data } = recyclerViewManager.props; -+ const { horizontal, data, inverted } = recyclerViewManager.props; - // Execute all pending callbacks from previous scroll offset updates - // This ensures any scroll operations that were waiting for render are completed - const callbacks = pendingScrollCallbacks.current; -@@ -91,6 +93,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - currentDataLength > 0 && - recyclerViewManager.shouldMaintainVisibleContentPosition()) { - const hasDataChanged = currentDataLength !== lastDataLengthRef.current; -+ // Read and reset the skip flag so it never persists across multiple correction cycles -+ const shouldSkipAndroidInvertedCorrection = hasDataChanged && inverted && Platform.OS === 'android' && skipNextAndroidInvertedCorrection.current; -+ if (shouldSkipAndroidInvertedCorrection) { -+ skipNextAndroidInvertedCorrection.current = false; -+ } - // If we have a tracked first visible item, maintain its position - if (firstVisibleItemKey.current) { - const currentIndexOfFirstVisibleItem = (_a = recyclerViewManager -@@ -115,10 +122,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - !pauseOffsetCorrection.current && - !recyclerViewManager.animationOptimizationsEnabled) { - // console.log("diff", diff, firstVisibleItemKey.current); -- if (PlatformConfig.supportsOffsetCorrection) { -- // console.log("scrollBy", diff); -+ const useAndroidInvertedFallback = hasDataChanged && inverted && Platform.OS === 'android'; -+ if (PlatformConfig.supportsOffsetCorrection && !useAndroidInvertedFallback) { - (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff); - } -+ else if (useAndroidInvertedFallback) { -+ if (!shouldSkipAndroidInvertedCorrection) { -+ const scrollToParams = horizontal -+ ? { -+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ animated: false, -+ } -+ : { -+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ animated: false, -+ }; -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ } -+ // rAF scrollTo to correct after native layout commits -+ pendingAndroidInvertedRafId.current = requestAnimationFrame(() => { -+ pendingAndroidInvertedRafId.current = null; -+ (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); -+ }); -+ } -+ } - else { - const scrollToParams = horizontal - ? { -@@ -162,6 +190,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - * Handles RTL layouts and first item offset adjustments. - */ - scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }) => { -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ pendingAndroidInvertedRafId.current = null; -+ } -+ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { -+ skipNextAndroidInvertedCorrection.current = true; -+ } - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current) { - // Adjust offset for RTL layouts in horizontal mode -@@ -205,6 +240,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - * Scrolls to the end of the list. - */ - scrollToEnd: async ({ animated } = {}) => { -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ pendingAndroidInvertedRafId.current = null; -+ } -+ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { -+ skipNextAndroidInvertedCorrection.current = true; -+ } - const { data } = recyclerViewManager.props; - if (data && data.length > 0) { - const lastIndex = data.length - 1; -@@ -238,6 +280,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - if (!recyclerViewManager.hasLayout()) { - return Promise.resolve(); - } -+ if (pendingAndroidInvertedRafId.current !== null) { -+ cancelAnimationFrame(pendingAndroidInvertedRafId.current); -+ pendingAndroidInvertedRafId.current = null; -+ } - return new Promise((resolve) => { - const { horizontal } = recyclerViewManager.props; - if (scrollViewRef.current && diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch deleted file mode 100644 index 7f1700a548d9..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch +++ /dev/null @@ -1,80 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ee42f63..4e8d8c0 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -380,16 +380,15 @@ const RecyclerViewComponent = (props, ref) => { - } - return onScrollHandler; - }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); -- const shouldMaintainVisibleContentPosition = recyclerViewManager.shouldMaintainVisibleContentPosition(); - const maintainVisibleContentPositionInternal = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null && !maintainVisibleContentPosition.disabled) { - return { - ...maintainVisibleContentPosition, - minIndexForVisible: 0, - }; - } - return undefined; -- }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); -+ }, [maintainVisibleContentPosition]); - const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 && - ((_d = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _d !== void 0 ? _d : false); - // Create view for measuring bounded size -@@ -401,11 +399,11 @@ const RecyclerViewComponent = (props, ref) => { - }, ref: firstChildViewRef })); - }, [horizontal, stickyHeaderOffset]); - const scrollAnchor = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null) { - return (React.createElement(ScrollAnchor, { horizontal: Boolean(horizontal), scrollAnchorRef: scrollAnchorRef })); - } - return null; -- }, [horizontal, shouldMaintainVisibleContentPosition]); -+ }, [horizontal, maintainVisibleContentPosition]); - // console.log("render", recyclerViewManager.getRenderStack()); - // Render the main RecyclerView structure - return (React.createElement(RecyclerViewContextProvider, { value: recyclerViewContext }, -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index b2bd67a..d4bf02d 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -572,18 +572,15 @@ const RecyclerViewComponent = ( - return onScrollHandler; - }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); - -- const shouldMaintainVisibleContentPosition = -- recyclerViewManager.shouldMaintainVisibleContentPosition(); -- - const maintainVisibleContentPositionInternal = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null && !maintainVisibleContentPosition.disabled) { - return { - ...maintainVisibleContentPosition, - minIndexForVisible: 0, - }; - } - return undefined; -- }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); -+ }, [maintainVisibleContentPosition]); - - const shouldRenderFromBottom = - recyclerViewManager.getDataLength() > 0 && -@@ -604,7 +600,7 @@ const RecyclerViewComponent = ( - }, [horizontal, stickyHeaderOffset]); - - const scrollAnchor = useMemo(() => { -- if (shouldMaintainVisibleContentPosition) { -+ if (maintainVisibleContentPosition != null) { - return ( - ( - ); - } - return null; -- }, [horizontal, shouldMaintainVisibleContentPosition]); -+ }, [horizontal, maintainVisibleContentPosition]); - - // console.log("render", recyclerViewManager.getRenderStack()); - diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch deleted file mode 100644 index a76bba73cc2d..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 51b6f8c..d4ca252 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -507,7 +507,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - setTimeout(() => { - recyclerViewManager.isInitialScrollComplete = true; - pauseOffsetCorrection.current = false; -- }, 100); -+ }, 500); - pauseOffsetCorrection.current = true; - const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; - const offset = horizontal diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch deleted file mode 100644 index 928c0d6c28a6..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -316,7 +316,10 @@ function RecyclerView(props) { - */ - const validateItemSize = useCallback((index, size) => { - var _a, _b, _c, _d; -- const layout = recyclerViewManager.getLayout(index); -+ const layout = recyclerViewManager.tryGetLayout(index); -+ if (layout === undefined) { -+ return; -+ } - const width = Math.max(Math.min(layout.width, (_a = layout.maxWidth) !== null && _a !== void 0 ? _a : Infinity), (_b = layout.minWidth) !== null && _b !== void 0 ? _b : 0); - const height = Math.max(Math.min(layout.height, (_c = layout.maxHeight) !== null && _c !== void 0 ? _c : Infinity), (_d = layout.minHeight) !== null && _d !== void 0 ? _d : 0); - if (areDimensionsNotEqual(width, size.width) || -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -465,6 +465,9 @@ function RecyclerView(props: RecyclerViewProps) { - const validateItemSize = useCallback( - (index: number, size: RVDimension) => { -- const layout = recyclerViewManager.getLayout(index); -+ const layout = recyclerViewManager.tryGetLayout(index); -+ if (layout === undefined) { -+ return; -+ } - const width = Math.max( - Math.min(layout.width, layout.maxWidth ?? Infinity), - layout.minWidth ?? 0 diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch deleted file mode 100644 index c66e0497ccfd..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js b/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js -index 17d9812..fe112f1 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js -@@ -28,7 +28,10 @@ export function areDimensionsEqual(value1, value2) { - return Math.abs(value1 - value2) <= 1; - } - export function roundOffPixel(value) { -- return value; -+ const dpr = typeof window !== "undefined" && window.devicePixelRatio -+ ? window.devicePixelRatio -+ : 1; -+ return Math.round(value * dpr) / dpr; - } - /** - * Measures the size of the RecyclerView's outer container. diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch deleted file mode 100644 index 321a812fa2cc..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch +++ /dev/null @@ -1,542 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -index 08b83f3..05a64b1 100644 ---- a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -+++ b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -@@ -167,6 +167,30 @@ export interface FlashListRef { - * }); - */ - scrollToIndex: (params: ScrollToIndexParams) => Promise; -+ /** -+ * Announces an imminent programmatic scroll before `scrollToIndex` is -+ * actually called, so DOM-mutating side-effects gated on -+ * `isScrollingProgrammatically()` (notably the on-web sort applied by -+ * `ViewHolderCollection`) defer until the upcoming smooth scroll -+ * settles, rather than running synchronously and cancelling it. -+ * -+ * Useful when the focus assignment happens first and `scrollToIndex` -+ * follows a few ticks later — as long as the call is guaranteed to -+ * happen, queue it up front so the intervening `focusin` doesn't -+ * trigger an immediate sort that the smooth scroll would then cancel. -+ * -+ * Cleared automatically when the next `scrollToIndex` is invoked -+ * (handed off to the in-flight flag) and again when the resulting -+ * scroll's momentum ends. Safe to call multiple times. -+ * -+ * @example -+ * listRef.current?.announceProgrammaticScroll(); -+ * itemDomNode.focus(); -+ * setTimeout(() => { -+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); -+ * }, 0); -+ */ -+ announceProgrammaticScroll: () => void; - /** - * Scrolls to a specific item in the list. - * -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 4e53325..ef4daf2 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { - const refHolder = useMemo(() => new Map(), []); - // Initialize core RecyclerView manager and content offset management - const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); -- const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); -+ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, notifyScrollActive, notifyScrollSettled, getLastScrollTime, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); - // Initialize view holder collection ref - const viewHolderCollectionRef = useRef(null); - // Hook to handle list loading -@@ -238,12 +238,19 @@ const RecyclerViewComponent = (props, ref) => { - return; - } - if (isMomentumEnd) { -+ notifyScrollSettled(); -+ // Drain BEFORE the early return below so the drain still -+ // fires while offset projection is still disabled. -+ notifyProgrammaticScrollSettled(); - computeFirstVisibleIndexForOffsetCorrection(); - if (!recyclerViewManager.isOffsetProjectionEnabled) { - return; - } - recyclerViewManager.resetVelocityCompute(); - } -+ else { -+ notifyScrollActive(); -+ } - // Update scroll position and trigger re-render if needed - if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { - setRenderId((prev) => prev + 1); -@@ -266,6 +273,9 @@ const RecyclerViewComponent = (props, ref) => { - computeFirstVisibleIndexForOffsetCorrection, - horizontal, - isHorizontalRTL, -+ notifyProgrammaticScrollSettled, -+ notifyScrollActive, -+ notifyScrollSettled, - recyclerViewManager, - velocityTracker, - ]); -@@ -461,7 +471,7 @@ const RecyclerViewComponent = (props, ref) => { - recyclerViewManager.animationOptimizationsEnabled = false; - }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() - ? recyclerViewManager.getChildContainerDimensions() -- : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted }), -+ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, isScrolling: isScrolling, runAfterProgrammaticScroll: runAfterProgrammaticScroll, getLastScrollTime: getLastScrollTime }), - renderEmpty, - renderFooter), - stickyHeaderIndices && stickyHeaderIndices.length > 0 -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js -index 0df2879..f639313 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js -@@ -3,9 +3,11 @@ - * It handles the rendering of list items, separators, and manages layout updates for each item. - * The component is memoized to prevent unnecessary re-renders and includes layout comparison logic. - */ -+import { Platform } from "react-native"; - import React, { useCallback, useLayoutEffect, useMemo, useRef, } from "react"; - import { CompatView } from "./components/CompatView"; - import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle"; -+const INVISIBLE_MARKER_STYLE = { display: "none" }; - /** - * Internal ViewHolder component that handles the actual rendering of list items - * @template TItem - The type of item being rendered in the list -@@ -57,6 +59,7 @@ const ViewHolderInternal = (props) => { - const CompatContainer = (CellRendererComponent !== null && CellRendererComponent !== void 0 ? CellRendererComponent : CompatView); - return (React.createElement(CompatContainer, { ref: viewRef, onLayout: onLayout, style: style, index: index }, - children, -+ Platform.OS === "web" && (React.createElement("div", { "data-flashlist-index": index, "aria-hidden": true, style: INVISIBLE_MARKER_STYLE })), - separator)); - }; - /** -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -index c37c4f3..fd2ff94 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -@@ -54,6 +54,14 @@ export interface ViewHolderCollectionProps { - isInLastRow: (index: number) => boolean; - /** Whether the list is inverted */ - inverted: FlashListProps["inverted"]; -+ /** True while a programmatic scroll is queued or in flight. */ -+ isScrollingProgrammatically: () => boolean; -+ /** True while any scroll is in flight. */ -+ isScrolling: () => boolean; -+ /** Register a callback to run when the current programmatic-scroll animation settles. */ -+ runAfterProgrammaticScroll: (cb: () => void) => void; -+ /** Returns the timestamp (`Date.now()`) of the most recent scroll event, or 0 if none. */ -+ getLastScrollTime: () => number; - } - /** - * Ref interface for ViewHolderCollection that exposes methods to control layout updates -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..e66d406 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -3,17 +3,81 @@ - * It handles the rendering of a collection of list items, manages layout updates, - * and coordinates with the RecyclerView context for layout changes. - */ --import React, { useEffect, useImperativeHandle, useLayoutEffect } from "react"; -+import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useReducer, useRef, } from "react"; -+import { Platform } from "react-native"; - import { ViewHolder } from "./ViewHolder"; - import { CompatView } from "./components/CompatView"; - import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; -+const SORT_DELAY_MS = 1000; -+// Max gap from last `focusin` to last `scroll` event for the scroll to -+// count as a focus-induced auto-scroll-into-view (vs a user-driven scroll). -+const FOCUS_INDUCED_SCROLL_WINDOW_MS = 30; -+/** -+ * Single-slot setTimeout with a fire-time gate. Calling `schedule` again -+ * replaces any pending fire. When the timer expires, if `shouldDefer()` -+ * returns true the timer reschedules itself instead of invoking -+ * `callback`. Auto-cancels on unmount. -+ * -+ * @returns A tuple of `[schedule, cancel]`. `schedule` arms (or re-arms) -+ * the timer; `cancel` evicts whatever is in the slot. -+ */ -+function useDeferredCallback(callback, delayMs, shouldDefer) { -+ const timeoutRef = useRef(null); -+ const cancel = useCallback(() => { -+ if (timeoutRef.current !== null) { -+ clearTimeout(timeoutRef.current); -+ timeoutRef.current = null; -+ } -+ }, []); -+ const schedule = useCallback(() => { -+ cancel(); -+ timeoutRef.current = setTimeout(() => { -+ if (shouldDefer()) { -+ schedule(); -+ return; -+ } -+ timeoutRef.current = null; -+ callback(); -+ }, delayMs); -+ }, [callback, delayMs, shouldDefer, cancel]); -+ useEffect(() => cancel, [cancel]); -+ return [schedule, cancel]; -+} -+/** -+ * Walks up from `target` to find a `data-flashlist-index` marker among -+ * a parent's direct children, returning the marker's `index` and the -+ * walk-up `depth` (number of `parentElement` hops). Iterates siblings -+ * last-to-first — the marker sits between `{children}` and `{separator}` -+ * inside the ViewHolder, so it's near the end. Returns `null` if no -+ * marker is found before reaching `root`. -+ */ -+function findFocusedIndexFromMarker(target, root) { -+ var _a; -+ let current = target; -+ let depth = 0; -+ while (current && current !== root) { -+ const parent = current.parentElement; -+ if (!parent) -+ break; -+ for (let i = parent.children.length - 1; i >= 0; i--) { -+ const child = parent.children[i]; -+ const idxStr = (_a = child.dataset) === null || _a === void 0 ? void 0 : _a.flashlistIndex; -+ if (idxStr != null) { -+ return { index: Number(idxStr), depth }; -+ } -+ } -+ current = parent; -+ depth++; -+ } -+ return null; -+} - /** - * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances - * and handles layout updates for the entire collection - * @template TItem - The type of items in the data array - */ - export const ViewHolderCollection = (props) => { -- const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, } = props; -+ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, getLastScrollTime, } = props; - const [renderId, setRenderId] = React.useState(0); - const containerLayout = getChildContainerLayout(); - const fixedContainerSize = horizontal -@@ -72,9 +136,160 @@ export const ViewHolderCollection = (props) => { - // return `${index} => ${reactKey}`; - // }) - // ); -- return (React.createElement(CompatView, { style: hasData && containerStyle }, containerLayout && -+ const containerRef = useRef(null); -+ const lastFocusTimeRef = useRef(0); -+ const lastFocusedIndexRef = useRef(null); -+ const lastFocusedDepthRef = useRef(null); -+ const shouldSortOnNextFocusRef = useRef(false); -+ const renderEntriesRef = useRef(Array.from(renderStack.entries())); -+ // Tracks the modality of the user's most recent input ("pointer" vs -+ // "keyboard"). Pointer interactions defer the sync sort to avoid -+ // re-rendering between `mousedown` and `click` (which makes the browser -+ // drop the click). -+ const lastInputModalityRef = useRef("pointer"); -+ const [, bumpSortVersion] = useReducer((x) => x + 1, 0); -+ const sortItems = useCallback(() => { -+ const entries = renderEntriesRef.current; -+ const direction = inverted ? -1 : 1; -+ const isSorted = entries.every((entry, i) => i === 0 || direction * (entries[i - 1][1].index - entry[1].index) <= 0); -+ if (isSorted) { -+ return; -+ } -+ entries.sort(([, a], [, b]) => direction * (a.index - b.index)); -+ bumpSortVersion(); -+ }, [inverted]); -+ const [schedulePendingSort, clearPendingSort] = useDeferredCallback(sortItems, SORT_DELAY_MS, isScrolling); -+ const maybeDoSortOnFocus = useCallback(() => { -+ clearPendingSort(); -+ if (isScrollingProgrammatically()) { -+ runAfterProgrammaticScroll(schedulePendingSort); -+ return; -+ } -+ // Pointer-driven focus: defer the sync sort so we don't reorder the -+ // DOM between `mousedown` and `click` (the browser would drop the -+ // click). The pending sort will commit later via the timer. -+ if (shouldSortOnNextFocusRef.current && -+ lastInputModalityRef.current === "pointer") { -+ schedulePendingSort(); -+ return; -+ } -+ if (shouldSortOnNextFocusRef.current) { -+ shouldSortOnNextFocusRef.current = false; -+ sortItems(); -+ } -+ schedulePendingSort(); -+ }, [ -+ isScrollingProgrammatically, -+ runAfterProgrammaticScroll, -+ schedulePendingSort, -+ clearPendingSort, -+ sortItems, -+ ]); -+ const maybeDoSortOnScroll = useCallback(() => { -+ shouldSortOnNextFocusRef.current = true; -+ // Evict any stale timer from a previous scroll's drain so it can't -+ // fire mid-scroll during rapid-fire arrow nav (where `isMomentumEnd` -+ // doesn't fire between key presses). -+ clearPendingSort(); -+ if (isScrollingProgrammatically()) { -+ runAfterProgrammaticScroll(schedulePendingSort); -+ return; -+ } -+ if (isScrolling()) { -+ // Focus-induced auto-scroll-into-view: sort sync to keep DOM -+ // aligned for the next Tab. User-driven scrolls (negative Δ or Δ -+ // past the window) defer to avoid sorting mid-mousewheel. -+ const scrollSinceFocus = getLastScrollTime() - lastFocusTimeRef.current; -+ const scrollNow = scrollSinceFocus >= 0 && -+ scrollSinceFocus < FOCUS_INDUCED_SCROLL_WINDOW_MS; -+ if (scrollNow) { -+ sortItems(); -+ shouldSortOnNextFocusRef.current = false; -+ return; -+ } -+ } -+ schedulePendingSort(); -+ }, [ -+ isScrollingProgrammatically, -+ isScrolling, -+ runAfterProgrammaticScroll, -+ schedulePendingSort, -+ clearPendingSort, -+ sortItems, -+ getLastScrollTime, -+ ]); -+ if (Platform.OS === "web") { -+ // Reconcile: remove stale keys, append new keys -+ const existingKeys = new Set(renderEntriesRef.current.map(([key]) => key)); -+ renderEntriesRef.current = renderEntriesRef.current.filter(([key]) => renderStack.has(key)); -+ for (const key of renderStack.keys()) { -+ if (!existingKeys.has(key)) { -+ renderEntriesRef.current.push([key, renderStack.get(key)]); -+ } -+ } -+ } -+ else { -+ renderEntriesRef.current = Array.from(renderStack.entries()); -+ } -+ useEffect(() => { -+ const container = containerRef.current; -+ if (Platform.OS !== "web" || !container) { -+ return; -+ } -+ const onFocusIn = (e) => { -+ var _a, _b; -+ // Filter spurious focusins (recycle re-focus, mutation-phase -+ // phantoms). -+ const focused = findFocusedIndexFromMarker(e.target, containerRef.current); -+ const focusedIndex = (_a = focused === null || focused === void 0 ? void 0 : focused.index) !== null && _a !== void 0 ? _a : null; -+ const focusedDepth = (_b = focused === null || focused === void 0 ? void 0 : focused.depth) !== null && _b !== void 0 ? _b : null; -+ const isSameLogicalRow = focusedIndex !== null && -+ focusedIndex === lastFocusedIndexRef.current && -+ focusedDepth === lastFocusedDepthRef.current; -+ const isPhantomMutationFocus = e.relatedTarget === null && focusedIndex !== null; -+ if (isSameLogicalRow || isPhantomMutationFocus) { -+ return; -+ } -+ lastFocusedIndexRef.current = focusedIndex; -+ lastFocusedDepthRef.current = focusedDepth; -+ lastFocusTimeRef.current = Date.now(); -+ maybeDoSortOnFocus(); -+ }; -+ container.addEventListener("focusin", onFocusIn); -+ return () => container.removeEventListener("focusin", onFocusIn); -+ }, [maybeDoSortOnFocus]); -+ useEffect(() => { -+ if (Platform.OS !== "web") { -+ return; -+ } -+ maybeDoSortOnScroll(); -+ return clearPendingSort; -+ // eslint-disable-next-line react-hooks/exhaustive-deps -+ }, [renderStack, renderId]); -+ // Track input modality globally. Capture-phase document listeners so -+ // we observe events before any handler can call `stopPropagation()`. -+ // `pointerdown` covers mouse/touch/pen; `keydown` covers Tab and -+ // assistive technologies (e.g. VoiceOver injects keydowns). -+ useEffect(() => { -+ if (Platform.OS !== "web") { -+ return; -+ } -+ const onDocKeyDown = () => { -+ lastInputModalityRef.current = "keyboard"; -+ }; -+ const onDocPointerDown = () => { -+ lastInputModalityRef.current = "pointer"; -+ }; -+ document.addEventListener("keydown", onDocKeyDown, true); -+ document.addEventListener("pointerdown", onDocPointerDown, true); -+ return () => { -+ document.removeEventListener("keydown", onDocKeyDown, true); -+ document.removeEventListener("pointerdown", onDocPointerDown, true); -+ }; -+ }, []); -+ return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && - hasData && -- Array.from(renderStack.entries(), ([reactKey, { index }]) => { -+ renderEntriesRef.current.map(([reactKey, { index }]) => { - const item = data[index]; - // Suppress separators for items in the last row to prevent - // height mismatch. The last data item has no separator (no -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts -index 62d55cd..b715484 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts -@@ -24,5 +24,12 @@ export declare function useRecyclerViewController(recyclerViewManager: Recycl - computeFirstVisibleIndexForOffsetCorrection: () => void; - applyInitialScrollIndex: () => void; - handlerMethods: FlashListRef; -+ isScrollingProgrammatically: () => boolean; -+ isScrolling: () => boolean; -+ runAfterProgrammaticScroll: (cb: () => void) => void; -+ notifyProgrammaticScrollSettled: () => void; -+ notifyScrollActive: () => void; -+ notifyScrollSettled: () => void; -+ getLastScrollTime: () => number; - }; - //# sourceMappingURL=useRecyclerViewController.d.ts.map -\ No newline at end of file -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 165b080..18e59ce 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -25,6 +25,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const isUnmounted = useUnmountFlag(); - const [_, setRenderId] = useState(0); - const pauseOffsetCorrection = useRef(false); -+ // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in -+ // flight. Cleared exactly once on `isMomentumEnd` via -+ // `notifyProgrammaticScrollSettled`. -+ const isProgrammaticScrollActiveRef = useRef(false); -+ // Set by `announceProgrammaticScroll()` to announce an imminent scroll. -+ // Handed off to `isProgrammaticScrollActiveRef` at `scrollToIndex` entry. -+ const isProgrammaticScrollQueuedRef = useRef(false); -+ // Source-agnostic "viewport in motion" flag. -+ const isScrollingRef = useRef(false); -+ // Timestamp of the most recent scroll event; used to correlate scroll -+ // and focus events for the focus-induced-scroll heuristic. -+ const lastScrollTimeRef = useRef(0); -+ // Holds at most one callback registered via `runAfterProgrammaticScroll`, -+ // drained from `notifyProgrammaticScrollSettled`. -+ const pendingAfterScrollRef = useRef(null); - const pendingAndroidInvertedRafId = useRef(null); - const skipNextAndroidInvertedCorrection = useRef(false); - const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); -@@ -180,6 +195,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - updateScrollOffsetWithCallback, - computeFirstVisibleIndexForOffsetCorrection, - ]); -+ const isScrollingProgrammatically = useCallback(() => isProgrammaticScrollActiveRef.current || -+ isProgrammaticScrollQueuedRef.current, []); -+ const isScrolling = useCallback(() => isScrollingRef.current, []); -+ const runAfterProgrammaticScroll = useCallback((cb) => { -+ pendingAfterScrollRef.current = cb; -+ }, []); -+ // Public API; see `FlashListRef#announceProgrammaticScroll`. -+ const announceProgrammaticScroll = useCallback(() => { -+ isProgrammaticScrollQueuedRef.current = true; -+ }, []); -+ // Invoked from `RecyclerView.onScrollHandler` on `isMomentumEnd` (~100ms -+ // after the last scroll event). Drains the pending callback if any. -+ const notifyProgrammaticScrollSettled = useCallback(() => { -+ isProgrammaticScrollActiveRef.current = false; -+ isProgrammaticScrollQueuedRef.current = false; -+ const cb = pendingAfterScrollRef.current; -+ pendingAfterScrollRef.current = null; -+ cb === null || cb === void 0 ? void 0 : cb(); -+ }, []); -+ const notifyScrollActive = useCallback(() => { -+ isScrollingRef.current = true; -+ lastScrollTimeRef.current = Date.now(); -+ }, []); -+ const notifyScrollSettled = useCallback(() => { -+ isScrollingRef.current = false; -+ }, []); -+ const getLastScrollTime = useCallback(() => lastScrollTimeRef.current, []); - const handlerMethods = useMemo(() => { - return { - get props() { -@@ -271,6 +313,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - animated, - }); - }, -+ /** -+ * Announces an imminent programmatic scroll. See -+ * `FlashListRef#announceProgrammaticScroll` for full semantics. -+ */ -+ announceProgrammaticScroll, - /** - * Scrolls to a specific index in the list. - * Supports viewPosition and viewOffset for precise positioning. -@@ -292,6 +339,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - // Pause the scroll offset adjustments - pauseOffsetCorrection.current = true; - recyclerViewManager.setOffsetProjectionEnabled(false); -+ // Cleared on `isMomentumEnd` via `notifyProgrammaticScrollSettled`. -+ // Hand off "queued" → "active" here so any stale queue flag -+ // can't gate sorts indefinitely. -+ isProgrammaticScrollQueuedRef.current = false; -+ isProgrammaticScrollActiveRef.current = true; - const getFinalOffset = () => { - const layout = recyclerViewManager.getLayout(index); - const offset = horizontal ? layout.x : layout.y; -@@ -496,6 +548,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - setTimeout, - isUnmounted, - updateScrollOffsetWithCallback, -+ announceProgrammaticScroll, - ]); - const applyInitialScrollIndex = useCallback(() => { - var _a, _b, _c; -@@ -550,6 +603,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - computeFirstVisibleIndexForOffsetCorrection, - applyInitialScrollIndex, - handlerMethods, -+ isScrollingProgrammatically, -+ isScrolling, -+ runAfterProgrammaticScroll, -+ notifyProgrammaticScrollSettled, -+ notifyScrollActive, -+ notifyScrollSettled, -+ getLastScrollTime, - }; - } - //# sourceMappingURL=useRecyclerViewController.js.map -\ No newline at end of file -diff --git a/node_modules/@shopify/flash-list/src/FlashListRef.ts b/node_modules/@shopify/flash-list/src/FlashListRef.ts -index 07bac2a..af9ee7d 100644 ---- a/node_modules/@shopify/flash-list/src/FlashListRef.ts -+++ b/node_modules/@shopify/flash-list/src/FlashListRef.ts -@@ -181,6 +181,31 @@ export interface FlashListRef { - */ - scrollToIndex: (params: ScrollToIndexParams) => Promise; - -+ /** -+ * Announces an imminent programmatic scroll before `scrollToIndex` is -+ * actually called, so DOM-mutating side-effects gated on -+ * `isScrollingProgrammatically()` (notably the on-web sort applied by -+ * `ViewHolderCollection`) defer until the upcoming smooth scroll -+ * settles, rather than running synchronously and cancelling it. -+ * -+ * Useful when the focus assignment happens first and `scrollToIndex` -+ * follows a few ticks later — as long as the call is guaranteed to -+ * happen, queue it up front so the intervening `focusin` doesn't -+ * trigger an immediate sort that the smooth scroll would then cancel. -+ * -+ * Cleared automatically when the next `scrollToIndex` is invoked -+ * (handed off to the in-flight flag) and again when the resulting -+ * scroll's momentum ends. Safe to call multiple times. -+ * -+ * @example -+ * listRef.current?.announceProgrammaticScroll(); -+ * itemDomNode.focus(); -+ * setTimeout(() => { -+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); -+ * }, 0); -+ */ -+ announceProgrammaticScroll: () => void; -+ - /** - * Scrolls to a specific item in the list. - * diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch deleted file mode 100644 index 03a1dbf1249d..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch +++ /dev/null @@ -1,126 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -index 12375d9..bfc3ee2 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -@@ -1,4 +1,13 @@ -+import { Platform } from "react-native"; - import { RVLayoutManager, } from "./LayoutManager"; -+// How many recent widths we keep while watching for a scrollbar flicker. -+const BOUNDED_SIZE_HISTORY_LENGTH = 8; -+// One scrollbar toggle only flips twice, so three means it's bouncing. -+const MIN_OSCILLATION_FLIPS = 3; -+// Scrollbars are ~15-17px. A jump this small is a scrollbar, not a resize. -+const SCROLLBAR_OSCILLATION_TOLERANCE = 25; -+// How many times we trust the wider width before we keep the lock instead. -+const MAX_LOCK_RELEASE_CYCLES = 1; - /** - * LinearLayoutManager implementation that arranges items in a single row or column. - * Supports both horizontal and vertical layouts with dynamic item sizing. -@@ -10,6 +19,8 @@ - this.hasSize = false; - /** Height of the tallest item */ - this.tallestItemHeight = 0; -+ /** How many times the scrollbar lock has been released for the current pair of widths */ -+ this.scrollbarLockReleases = 0; - this.boundedSize = this.horizontal - ? params.windowSize.height - : params.windowSize.width; -@@ -23,9 +34,15 @@ - const prevHorizontal = this.horizontal; - super.updateLayoutParams(params); - const oldBoundedSize = this.boundedSize; -- this.boundedSize = this.horizontal -+ const measuredBoundedSize = this.horizontal - ? params.windowSize.height - : params.windowSize.width; -+ // On web, a scrollbar showing and hiding can kick off an endless re-layout loop that -+ // crashes the app. Native scrollbars float on top and never do this, so only guard on web. -+ this.boundedSize = -+ Platform.OS === "web" -+ ? this.settleScrollbarOscillation(measuredBoundedSize) -+ : measuredBoundedSize; - if (oldBoundedSize !== this.boundedSize || - prevHorizontal !== this.horizontal) { - if (this.layouts.length > 0) { -@@ -36,6 +53,81 @@ - } - } - /** -+ * Web only. Stops the re-layout loop caused by a scrollbar that keeps showing and hiding. -+ * -+ * The list measures its width without the scrollbar, so each toggle changes the width, which -+ * relayouts, which changes the height, which toggles the scrollbar again. React eventually -+ * gives up with "Maximum update depth exceeded" (#185). -+ * -+ * We watch for a width bouncing between two values a scrollbar apart and lock to the smaller -+ * one. The toggle can lag a frame, so the bounce is often A,A,B,B rather than A,B,A,B. A real -+ * resize passes through many widths and never looks like this. -+ * -+ * The lock has to survive the wider width. These lists get shorter as they narrow, so once we -+ * lock, the scrollbar goes away and the wider width comes right back. Letting go every time -+ * just slows the loop down. -+ * @param measuredBoundedSize Cross-axis size we just measured -+ * @returns The cross-axis size to lay out with -+ */ -+ settleScrollbarOscillation(measuredBoundedSize) { -+ var _a; -+ // Round to whole pixels so subpixel drift doesn't break the checks below. -+ const size = Math.round(measuredBoundedSize); -+ const settledPair = this.scrollbarOscillationPair; -+ if (settledPair) { -+ const [smaller, larger] = settledPair; -+ // Still on the smaller width, so the scrollbar is still there. Keep the lock. -+ if (size === smaller) { -+ return smaller; -+ } -+ if (size === larger) { -+ // Wider again. Either the scrollbar went away for good (happens once) or the -+ // flicker is still going (happens every round). Trust it once, then stop. -+ // Not reset on re-lock, or a flicker would top it up forever. -+ this.scrollbarLockReleases++; -+ if (this.scrollbarLockReleases > MAX_LOCK_RELEASE_CYCLES) { -+ return smaller; -+ } -+ } -+ else { -+ // Some other width, so this is a real resize and the pair is stale. -+ this.scrollbarLockReleases = 0; -+ } -+ this.scrollbarOscillationPair = undefined; -+ this.recentBoundedSizes = undefined; -+ } -+ const history = ((_a = this.recentBoundedSizes) !== null && _a !== void 0 ? _a : (this.recentBoundedSizes = [])); -+ history.push(size); -+ if (history.length > BOUNDED_SIZE_HISTORY_LENGTH) { -+ history.shift(); -+ } -+ const distinctSizes = Array.from(new Set(history)); -+ if (distinctSizes.length !== 2 || -+ Math.abs(distinctSizes[0] - distinctSizes[1]) > SCROLLBAR_OSCILLATION_TOLERANCE) { -+ return measuredBoundedSize; -+ } -+ let flips = 0; -+ for (let i = 1; i < history.length; i++) { -+ if (history[i] !== history[i - 1]) { -+ flips++; -+ } -+ } -+ if (flips < MIN_OSCILLATION_FLIPS) { -+ return measuredBoundedSize; -+ } -+ const smaller = Math.min(distinctSizes[0], distinctSizes[1]); -+ // Only lock on a smaller-width frame so we lock to the width that's on screen. An old -+ // bounce can still be in the history, and locking then would shrink rows for no reason. -+ if (size !== smaller) { -+ return measuredBoundedSize; -+ } -+ this.scrollbarOscillationPair = [ -+ smaller, -+ Math.max(distinctSizes[0], distinctSizes[1]), -+ ]; -+ return smaller; -+ } -+ /** - * Processes layout information for items, updating their dimensions. - * For horizontal layouts, also normalizes heights of items. - * @param layoutInfo Array of layout information for items diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch deleted file mode 100644 index c11114fa1de0..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ /dev/null @@ -1,166 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..586014c 100644 ---- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -+++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -@@ -127,10 +127,12 @@ export interface FlashListProps extends Omit 0) { -+ initialItemOffset = Math.max(0, initialItemOffset - (windowSize - itemSize) * viewPosition); -+ } -+ } - this.engagedIndicesTracker.scrollOffset = initialItemOffset; - } - else { -@@ -317,8 +332,20 @@ export class RecyclerViewManager { - this.applyInitialScrollAdjustment(); - const visibleIndices = this.computeVisibleIndices(); - // console.log("---------> visibleIndices", visibleIndices); -- this.hasRenderedProgressively = visibleIndices.every((index) => layoutManager.getLayout(index).isHeightMeasured && -- layoutManager.getLayout(index).isWidthMeasured); -+ const isFullyMeasured = (index) => layoutManager.getLayout(index).isHeightMeasured && -+ layoutManager.getLayout(index).isWidthMeasured; -+ // When scrolling to a positive initialScrollIndex, also wait for the drawDistance buffer to be measured before -+ // completing the first layout, so estimate-driven layout shifts converge before anything is on screen. Index 0 offsets by at most the list header, and the -1 sentinel does not scroll at all, so both keep stock progressive render. -+ let targetIndices = visibleIndices; -+ if ((this.propsRef.initialScrollIndex ?? 0) > 0 && visibleIndices.length > 0 && visibleIndices.every(isFullyMeasured)) { -+ const windowSize = this.propsRef.horizontal ? this.getWindowSize().width : this.getWindowSize().height; -+ const viewportStart = this.engagedIndicesTracker.scrollOffset; -+ // Cover the worst-case one-sided buffer the engaged tracker can mount after -+ // first layout (totalBuffer * largeMultiplier in the scroll direction). -+ const bufferDistance = this.engagedIndicesTracker.drawDistance * 2 * this.engagedIndicesTracker.largeMultiplier; -+ targetIndices = layoutManager.getVisibleLayouts(Math.max(0, viewportStart - bufferDistance), viewportStart + windowSize + bufferDistance); -+ } -+ this.hasRenderedProgressively = targetIndices.every(isFullyMeasured); - if (this.hasRenderedProgressively) { - this.isFirstLayoutComplete = true; - } -@@ -327,9 +354,13 @@ export class RecyclerViewManager { - // If everything is measured then render stack will be in sync. The buffer items will get rendered in the next update - // triggered by the useOnLoad hook. - !this.hasRenderedProgressively && -- this.updateRenderStack( -- // pick first n indices from visible ones based on batch size -- visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize))); -+ this.updateRenderStack(targetIndices === visibleIndices -+ ? // pick first n indices from visible ones based on batch size -+ visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize)) -+ : // buffer phase: visible items are already measured, mount the whole -+ // buffer window at once. Same single-commit cost the engaged tracker -+ // would pay post-paint, just moved to where nothing is visible yet. -+ targetIndices); - } - } - getItemType(index) { -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 18e59ce..40bdddb 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -25,6 +25,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - const isUnmounted = useUnmountFlag(); - const [_, setRenderId] = useState(0); - const pauseOffsetCorrection = useRef(false); -+ // Latest offset computed by applyInitialScrollIndex. The deferred (setTimeout) re-scroll reads this at -+ // fire-time instead of the value it closed over, so a stale timeout scheduled by an earlier commit can't -+ // snap back to an outdated offset after a newer commit. -+ const latestInitialScrollOffsetRef = useRef(0); - // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in - // flight. Cleared exactly once on `isMomentumEnd` via - // `notifyProgrammaticScrollSettled`. -@@ -566,18 +570,55 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - }, 500); - pauseOffsetCorrection.current = true; - const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; -- const offset = horizontal -- ? recyclerViewManager.getLayout(initialScrollIndex).x + additionalOffset -- : recyclerViewManager.getLayout(initialScrollIndex).y + -- additionalOffset; -+ const initialItemLayout = recyclerViewManager.getLayout(initialScrollIndex); -+ let offset = (horizontal ? initialItemLayout.x : initialItemLayout.y) + -+ additionalOffset; -+ // Position the target item within the viewport (0 = start, 0.5 = center, 1 = end), mirroring scrollToIndex. -+ const viewPosition = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewPosition; -+ if (viewPosition !== undefined) { -+ const containerSize = horizontal -+ ? recyclerViewManager.getWindowSize().width -+ : recyclerViewManager.getWindowSize().height; -+ const itemSize = horizontal -+ ? initialItemLayout.width -+ : initialItemLayout.height; -+ if (containerSize > 0) { -+ offset = Math.max(0, offset - (containerSize - itemSize) * viewPosition); -+ } -+ } -+ // Make it clear there are more items to scroll to underneath the bottom edge. -+ // If the bottom item is (essentially) fully visible against the bottom edge AND there -+ // is an item underneath it, nudge the bottom edge up so CROP_OFFSET px of the current -+ // bottom item gets cropped, signalling that more content can be scrolled into view. -+ if (viewPosition !== undefined && !horizontal && recyclerViewManager.props.inverted && offset > 0) { -+ const CROP_OFFSET = 10; -+ let bottomIndex = -1; -+ for (let i = initialScrollIndex; i >= 0; i--) { -+ if (recyclerViewManager.getLayout(i).y <= offset) { -+ bottomIndex = i; -+ break; -+ } -+ } -+ if (bottomIndex > 0) { -+ const bottomItemLayout = recyclerViewManager.getLayout(bottomIndex); -+ const hiddenPortion = offset - bottomItemLayout.y; -+ // 8px is bottom padding of every item -+ if (hiddenPortion <= 8) { -+ // Crop the current bottom item rather than letting it sit flush against the edge. -+ offset = bottomItemLayout.y + CROP_OFFSET; -+ } -+ } -+ } -+ latestInitialScrollOffsetRef.current = offset; - handlerMethods.scrollToOffset({ - offset, - animated: false, - skipFirstItemOffset: false, - }); -+ - setTimeout(() => { - handlerMethods.scrollToOffset({ -- offset, -+ offset: latestInitialScrollOffsetRef.current, - animated: false, - skipFirstItemOffset: false, - }); diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch deleted file mode 100644 index 971a05fa078b..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch +++ /dev/null @@ -1,104 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..014eb62 100644 ---- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -+++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -@@ -99,6 +99,15 @@ export interface FlashListProps extends Omit | React.ExoticComponent | React.FC; -+ /** -+ * When set, the list uses this as its visible window size instead of measuring its outer container. -+ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll -+ * events), where the outer container is as tall as the full content and can't be used as the viewport. -+ */ -+ overrideWindowSize?: { -+ width: number; -+ height: number; -+ }; - /** - * Draw distance for advanced rendering (in dp/px) - */ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ef4daf2..063fd1a 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -28,7 +28,7 @@ import { RenderTimeTracker } from "./helpers/RenderTimeTracker"; - const RecyclerViewComponent = (props, ref) => { - var _a, _b, _c, _d; - // Destructure props and initialize refs -- const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props; -+ const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, overrideWindowSize, ...rest } = props; - const [renderTimeTracker] = useState(() => new RenderTimeTracker()); - renderTimeTracker.startTracking(); - // Sticky header config -@@ -147,12 +147,15 @@ const RecyclerViewComponent = (props, ref) => { - : horizontal - ? firstChildViewLayout.x - : firstChildViewLayout.y; -+ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full -+ // content) declare its visible window; everything else still uses the real measurement. -+ const windowSize = overrideWindowSize !== null && overrideWindowSize !== void 0 ? overrideWindowSize : outerViewSize; - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams({ -- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, -+ width: horizontal ? windowSize.width : firstChildViewLayout.width, - height: horizontal - ? firstChildViewLayout.height -- : outerViewSize.height, -+ : windowSize.height, - }, isHorizontalRTL && recyclerViewManager.hasLayout() - ? firstItemOffset - - recyclerViewManager.getChildContainerDimensions().width -diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts -index 76dd0c8..5a5c0f5 100644 ---- a/node_modules/@shopify/flash-list/src/FlashListProps.ts -+++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts -@@ -160,6 +160,16 @@ export interface FlashListProps - | React.ExoticComponent - | React.FC; - -+ /** -+ * When set, the list uses this as its visible window size instead of measuring its outer container. -+ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll -+ * events), where the outer container is as tall as the full content and can't be used as the viewport. -+ */ -+ overrideWindowSize?: { -+ width: number; -+ height: number; -+ }; -+ - /** - * Draw distance for advanced rendering (in dp/px) - */ -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index bc27739..a7829d5 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -90,6 +90,7 @@ const RecyclerViewComponent = ( - onChangeStickyIndex, - stickyHeaderConfig, - inverted, -+ overrideWindowSize, - ...rest - } = props; - -@@ -265,13 +266,17 @@ const RecyclerViewComponent = ( - ? firstChildViewLayout.x - : firstChildViewLayout.y; - -+ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full -+ // content) declare its visible window; everything else still uses the real measurement. -+ const windowSize = overrideWindowSize ?? outerViewSize; -+ - // Update the RecyclerView manager with window dimensions - recyclerViewManager.updateLayoutParams( - { -- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, -+ width: horizontal ? windowSize.width : firstChildViewLayout.width, - height: horizontal - ? firstChildViewLayout.height -- : outerViewSize.height, -+ : windowSize.height, - }, - isHorizontalRTL && recyclerViewManager.hasLayout() - ? firstItemOffset - diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch deleted file mode 100644 index 5d59a58de2ff..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch +++ /dev/null @@ -1,358 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 063fd1a..c37ff3a 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -349,8 +349,27 @@ const RecyclerViewComponent = (props, ref) => { - recyclerViewContext.layout(); - } - }, [recyclerViewContext, recyclerViewManager]); -+ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component -+ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep -+ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass -+ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, -+ // so a header resize cannot shift item positions. -+ const lastHeaderSizeRef = useRef(-1); -+ const onHeaderLayout = useCallback((event) => { -+ if (inverted) { -+ return; -+ } -+ const headerSize = horizontal -+ ? event.nativeEvent.layout.width -+ : event.nativeEvent.layout.height; -+ if (lastHeaderSizeRef.current >= 0 && -+ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize)) { -+ recyclerViewContext.layout(); -+ } -+ lastHeaderSizeRef.current = headerSize; -+ }, [horizontal, inverted, recyclerViewContext]); - // Get secondary props and components -- const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props); -+ const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props, onHeaderLayout); - if (!recyclerViewManager.getIsFirstLayoutComplete() && - recyclerViewManager.getDataLength() > 0) { - parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.markChildLayoutAsPending(recyclerViewId); -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 40bdddb..c505e59 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -51,6 +51,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - // Track the first visible item for maintaining scroll position - const firstVisibleItemKey = useRef(undefined); - const firstVisibleItemLayout = useRef(undefined); -+ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are -+ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta -+ // must be captured separately or offset correction is blind to it. -+ const firstVisibleItemFirstItemOffset = useRef(0); - // Queue to store callbacks that should be executed after scroll offset updates - const pendingScrollCallbacks = useRef([]); - // Handle initial scroll position when the list first loads -@@ -82,6 +86,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - recyclerViewManager.hasStableDataKeys() && - recyclerViewManager.getDataLength() > 0 && - recyclerViewManager.shouldMaintainVisibleContentPosition()) { -+ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the -+ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can -+ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking -+ // the viewport away from what the user is looking at. The header's own top never moves, so the correct -+ // correction while it is the anchor is none — drop the tracked item instead. -+ if (recyclerViewManager.getAbsoluteLastScrollOffset() < -+ recyclerViewManager.firstItemOffset) { -+ firstVisibleItemKey.current = undefined; -+ return; -+ } - // Update the tracked first visible item - const firstVisibleIndex = Math.max(0, recyclerViewManager.computeVisibleIndices().startIndex); - if (firstVisibleIndex !== undefined && firstVisibleIndex >= 0) { -@@ -90,6 +104,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(firstVisibleIndex), - }; -+ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; - } - } - }, [recyclerViewManager]); -@@ -128,15 +143,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - : undefined); - if (currentIndexOfFirstVisibleItem !== undefined && - currentIndexOfFirstVisibleItem >= 0) { -- // Calculate the difference in position and apply the offset -- const diff = horizontal -+ // Calculate the difference in position and apply the offset. Item layouts are header-relative, -+ // so a ListHeaderComponent resize shifts every item on screen by the same amount without -+ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. -+ const layoutDiff = horizontal - ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - - firstVisibleItemLayout.current.x - : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - - firstVisibleItemLayout.current.y; -+ const diff = layoutDiff + -+ (recyclerViewManager.firstItemOffset - -+ firstVisibleItemFirstItemOffset.current); - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), - }; -+ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; - if (diff !== 0 && - !pauseOffsetCorrection.current && - !recyclerViewManager.animationOptimizationsEnabled) { -@@ -147,13 +168,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - } - else if (useAndroidInvertedFallback) { - if (!shouldSkipAndroidInvertedCorrection) { -+ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the -+ // tracker's relative offset only resyncs on scroll events), so the header delta is -+ // already embedded in it — add only the layout diff on scrollTo paths. - const scrollToParams = horizontal - ? { -- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - } - : { -- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - }; - if (pendingAndroidInvertedRafId.current !== null) { -@@ -169,17 +193,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - else { - const scrollToParams = horizontal - ? { -- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - } - : { -- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - animated: false, - }; - (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); - } - if (hasDataChanged) { -- updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + diff, () => { }); -+ updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, () => { }); - recyclerViewManager.ignoreScrollEvents = true; - setTimeout(() => { - recyclerViewManager.ignoreScrollEvents = false; -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js -index 4d7a945..26133d6 100644 ---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js -@@ -21,7 +21,7 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; - * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer - * - CompatScrollView: The animated scroll component - */ --export function useSecondaryProps(props) { -+export function useSecondaryProps(props, onHeaderLayout) { - const { ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListEmptyComponent, ListEmptyComponentStyle, renderScrollComponent, refreshing, progressViewOffset, onRefresh, data, refreshControl: customRefreshControl, stickyHeaderConfig, inverted, horizontal, } = props; - const invertedTransformStyle = inverted - ? getInvertedTransformStyle(horizontal) -@@ -45,8 +45,8 @@ export function useSecondaryProps(props) { - if (!ListHeaderComponent) { - return null; - } -- return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle] }, getValidComponent(ListHeaderComponent))); -- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); -+ return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle], onLayout: onHeaderLayout }, getValidComponent(ListHeaderComponent))); -+ }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle, onHeaderLayout]); - /** - * Creates the footer component with optional styling. - */ -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index a7829d5..ec5d7b7 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -16,6 +16,7 @@ import React, { - import { - Animated, - I18nManager, -+ LayoutChangeEvent, - NativeScrollEvent, - NativeSyntheticEvent, - Platform, -@@ -501,6 +502,31 @@ const RecyclerViewComponent = ( - [recyclerViewContext, recyclerViewManager] - ); - -+ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component -+ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep -+ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass -+ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, -+ // so a header resize cannot shift item positions. -+ const lastHeaderSizeRef = useRef(-1); -+ const onHeaderLayout = useCallback( -+ (event: LayoutChangeEvent) => { -+ if (inverted) { -+ return; -+ } -+ const headerSize = horizontal -+ ? event.nativeEvent.layout.width -+ : event.nativeEvent.layout.height; -+ if ( -+ lastHeaderSizeRef.current >= 0 && -+ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize) -+ ) { -+ recyclerViewContext.layout(); -+ } -+ lastHeaderSizeRef.current = headerSize; -+ }, -+ [horizontal, inverted, recyclerViewContext] -+ ); -+ - // Get secondary props and components - const { - refreshControl, -@@ -509,7 +535,7 @@ const RecyclerViewComponent = ( - renderEmpty, - CompatScrollView, - renderStickyHeaderBackdrop, -- } = useSecondaryProps(props); -+ } = useSecondaryProps(props, onHeaderLayout); - - if ( - !recyclerViewManager.getIsFirstLayoutComplete() && -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -index 3012391..7375752 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -@@ -57,6 +57,10 @@ export function useRecyclerViewController( - // Track the first visible item for maintaining scroll position - const firstVisibleItemKey = useRef(undefined); - const firstVisibleItemLayout = useRef(undefined); -+ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are -+ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta -+ // must be captured separately or offset correction is blind to it. -+ const firstVisibleItemFirstItemOffset = useRef(0); - - // Queue to store callbacks that should be executed after scroll offset updates - const pendingScrollCallbacks = useRef<(() => void)[]>([]); -@@ -96,6 +100,18 @@ export function useRecyclerViewController( - recyclerViewManager.getDataLength() > 0 && - recyclerViewManager.shouldMaintainVisibleContentPosition() - ) { -+ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the -+ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can -+ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking -+ // the viewport away from what the user is looking at. The header's own top never moves, so the correct -+ // correction while it is the anchor is none — drop the tracked item instead. -+ if ( -+ recyclerViewManager.getAbsoluteLastScrollOffset() < -+ recyclerViewManager.firstItemOffset -+ ) { -+ firstVisibleItemKey.current = undefined; -+ return; -+ } - // Update the tracked first visible item - const firstVisibleIndex = Math.max( - 0, -@@ -107,6 +123,8 @@ export function useRecyclerViewController( - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(firstVisibleIndex), - }; -+ firstVisibleItemFirstItemOffset.current = -+ recyclerViewManager.firstItemOffset; - } - } - }, [recyclerViewManager]); -@@ -156,15 +174,23 @@ export function useRecyclerViewController( - currentIndexOfFirstVisibleItem !== undefined && - currentIndexOfFirstVisibleItem >= 0 - ) { -- // Calculate the difference in position and apply the offset -- const diff = horizontal -+ // Calculate the difference in position and apply the offset. Item layouts are header-relative, -+ // so a ListHeaderComponent resize shifts every item on screen by the same amount without -+ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. -+ const layoutDiff = horizontal - ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - - firstVisibleItemLayout.current!.x - : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - - firstVisibleItemLayout.current!.y; -+ const diff = -+ layoutDiff + -+ (recyclerViewManager.firstItemOffset - -+ firstVisibleItemFirstItemOffset.current); - firstVisibleItemLayout.current = { - ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), - }; -+ firstVisibleItemFirstItemOffset.current = -+ recyclerViewManager.firstItemOffset; - if ( - diff !== 0 && - !pauseOffsetCorrection.current && -@@ -175,20 +201,27 @@ export function useRecyclerViewController( - // console.log("scrollBy", diff); - scrollAnchorRef.current?.scrollBy(diff); - } else { -+ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the -+ // tracker's relative offset only resyncs on scroll events), so the header delta is -+ // already embedded in it — add only the layout diff on scrollTo paths. - const scrollToParams = horizontal - ? { -- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ x: -+ recyclerViewManager.getAbsoluteLastScrollOffset() + -+ layoutDiff, - animated: false, - } - : { -- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ y: -+ recyclerViewManager.getAbsoluteLastScrollOffset() + -+ layoutDiff, - animated: false, - }; - scrollViewRef.current?.scrollTo(scrollToParams); - } - if (hasDataChanged) { - updateScrollOffsetWithCallback( -- recyclerViewManager.getAbsoluteLastScrollOffset() + diff, -+ recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, - () => {} - ); - recyclerViewManager.ignoreScrollEvents = true; -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx -index a64742c..7be3eb2 100644 ---- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx -@@ -1,4 +1,4 @@ --import { Animated, RefreshControl } from "react-native"; -+import { Animated, LayoutChangeEvent, RefreshControl } from "react-native"; - import React, { useMemo } from "react"; - - import { RecyclerViewProps } from "../RecyclerViewProps"; -@@ -24,7 +24,10 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; - * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer - * - CompatScrollView: The animated scroll component - */ --export function useSecondaryProps(props: RecyclerViewProps) { -+export function useSecondaryProps( -+ props: RecyclerViewProps, -+ onHeaderLayout?: (event: LayoutChangeEvent) => void -+) { - const { - ListHeaderComponent, - ListHeaderComponentStyle, -@@ -73,11 +76,19 @@ export function useSecondaryProps(props: RecyclerViewProps) { - return null; - } - return ( -- -+ - {getValidComponent(ListHeaderComponent)} - - ); -- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); -+ }, [ -+ ListHeaderComponent, -+ ListHeaderComponentStyle, -+ invertedTransformStyle, -+ onHeaderLayout, -+ ]); - - /** - * Creates the footer component with optional styling. diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch deleted file mode 100644 index 572d6b9790bf..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch +++ /dev/null @@ -1,97 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -467,7 +467,7 @@ function RecyclerView(props) { - isHorizontalRTL && viewToMeasureBoundedSize, - renderHeader, - !isHorizontalRTL && viewToMeasureBoundedSize, -- React.createElement(ViewHolderCollection, { viewHolderCollectionRef: viewHolderCollectionRef, data: data, horizontal: horizontal, renderStack: recyclerViewManager.getRenderStack(), getLayout: (index) => recyclerViewManager.getLayout(index), getAdjustmentMargin: () => { -+ React.createElement(ViewHolderCollection, { viewHolderCollectionRef: viewHolderCollectionRef, data: data, horizontal: horizontal, renderStack: recyclerViewManager.getRenderStack(), getLayout: (index) => recyclerViewManager.tryGetLayout(index), getAdjustmentMargin: () => { - if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) { - return 0; - } -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -@@ -19,7 +19,7 @@ export interface ViewHolderCollectionProps { - index: number; - }>; - /** Function to get layout information for a specific index */ -- getLayout: (index: number) => RVLayout; -+ getLayout: (index: number) => RVLayout | undefined; - /** Ref to control layout updates from parent components */ - viewHolderCollectionRef: React.Ref; - /** Map to store refs for each ViewHolder instance */ -diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js ---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -290,6 +290,13 @@ export function ViewHolderCollection(props) { - return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && - hasData && - renderEntriesRef.current.map(([reactKey, { index }]) => { -+ const layout = getLayout(index); -+ // The render stack can retain an entry whose index points past the -+ // end of the current layouts array when the data length shrinks -+ // mid-render. Skip it instead of throwing indexOutOfBounds. -+ if (layout === undefined) { -+ return null; -+ } - const item = data[index]; - // Suppress separators for items in the last row to prevent - // height mismatch. The last data item has no separator (no -@@ -298,7 +305,7 @@ export function ViewHolderCollection(props) { - ? data[index + 1] - : undefined; - return (React.createElement(ViewHolder, { key: reactKey, index: index, item: item, trailingItem: trailingItem, layout: { -- ...getLayout(index), -+ ...layout, - }, refHolder: refHolder, onSizeChanged: onSizeChanged, target: "Cell", renderItem: renderItem, extraData: extraData, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, horizontal: horizontal, hidden: hideStickyHeaderRelatedCell && currentStickyIndex === index, inverted: inverted })); - }))); - }; -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -705,7 +705,7 @@ function RecyclerView(props: RecyclerViewProps) { - data={data} - horizontal={horizontal} - renderStack={recyclerViewManager.getRenderStack()} -- getLayout={(index) => recyclerViewManager.getLayout(index)} -+ getLayout={(index) => recyclerViewManager.tryGetLayout(index)} - getAdjustmentMargin={() => { - if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) { - return 0; -diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx ---- a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx -+++ b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx -@@ -23,7 +23,7 @@ export interface ViewHolderCollectionProps { - /** Map of indices to React keys for each rendered item */ - renderStack: Map; - /** Function to get layout information for a specific index */ -- getLayout: (index: number) => RVLayout; -+ getLayout: (index: number) => RVLayout | undefined; - /** Ref to control layout updates from parent components */ - viewHolderCollectionRef: React.Ref; - /** Map to store refs for each ViewHolder instance */ -@@ -176,6 +176,13 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { - {containerLayout && - hasData && - Array.from(renderStack.entries(), ([reactKey, { index }]) => { -+ const layout = getLayout(index); -+ // The render stack can retain an entry whose index points past the -+ // end of the current layouts array when the data length shrinks -+ // mid-render. Skip it instead of throwing indexOutOfBounds. -+ if (layout === undefined) { -+ return null; -+ } - const item = data[index]; - // Suppress separators for items in the last row to prevent - // height mismatch. The last data item has no separator (no -@@ -192,7 +199,7 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { - item={item} - trailingItem={trailingItem} - layout={{ -- ...getLayout(index), -+ ...layout, - }} - refHolder={refHolder} - onSizeChanged={onSizeChanged} diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md deleted file mode 100644 index 3e4eeab058c3..000000000000 --- a/patches/@shopify/flash-list/details.md +++ /dev/null @@ -1,203 +0,0 @@ -# `@shopify/flash-list` patches - -### [@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch) - -- Reason: Fixes height normalization in horizontal FlashList when items change. `LinearLayoutManager.normalizeLayoutHeights` had three issues: - 1. **Screen resize / item shrink**: When items shrink, `tallestItemHeight` was updated prematurely, causing the next cycle to skip re-normalization. Fixed by resetting tallest item tracking when `targetMinHeight === 0` so the next repaint re-detects the tallest item. - 2. **Tallest item removed**: When the tallest item is deleted from the list, all remaining items kept the old `minHeight` forever because no item could pass the `height > minHeight` check. Fixed by detecting when `tallestItem` is no longer in `this.layouts` and resetting tracking with a repaint. - 3. **New smaller item added**: When the tallest item is already tracked, newly added items never got `minHeight` applied because there was no code path to normalize them. Fixed by applying `minHeight`/`height` to any unnormalized items when a tallest item is already tracked. -- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2096 -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/81566 - -### [@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch) - -- Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Four guards in total — two in `RecyclerView` to skip layout processing while hidden, and two in `useRecyclerViewController` to make scroll methods safe while hidden: - 1. **First `useLayoutEffect`** in `RecyclerView` (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions. - 2. **Second `useLayoutEffect`** in `RecyclerView` (measures individual items): If `containerViewSizeRef.current` is 0x0 (because the first effect bailed out), return early before calling `modifyChildrenLayout()`. This prevents item measurements taken under `display: none` (also 0) from corrupting stored layouts. - 3. **`scrollToIndex`** in `useRecyclerViewController`: When the list is hidden, guards 1/2 leave `layoutManager` undefined. Any `scrollToIndex` call (also reached via `scrollToEnd`, `scrollToItem`, `scrollToTop`) would then throw "LayoutManager is not initialized, window size is unavailable" from `recyclerViewManager.getWindowSize()`. Early-return a resolved Promise when `!recyclerViewManager.hasLayout()` so the call becomes a safe no-op; the list will scroll correctly on its next layout pass. - 4. **`scrollToOffset` RTL+horizontal branch** in `useRecyclerViewController`: Only the `I18nManager.isRTL && horizontal` branch reads `getChildContainerDimensions()` and `getWindowSize()`, both of which throw when `layoutManager` is undefined. Gate the branch on `recyclerViewManager.hasLayout()` so the RTL math is skipped while hidden; the non-RTL / vertical paths are unaffected and continue using the underlying `scrollViewRef.scrollTo()` directly. - When the container becomes visible again, `onLayout` fires (React Native Web uses ResizeObserver), triggering a re-render with correct dimensions so FlashList resumes normally without re-initialization. -- Files changed: `dist/recyclerview/RecyclerView.js` and `dist/recyclerview/hooks/useRecyclerViewController.js`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/83976 (original), https://github.com/Expensify/App/issues/90756 (scroll-while-hidden follow-up) -- PR introducing patch: https://github.com/Expensify/App/pull/84887 - -### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch) - -- Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) - -- Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. -- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2300 -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) - -- Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch) - -- Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component — an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/85114 - -### [@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch](@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch) - -- Reason: Fixes a scroll position reset on iOS when `maintainVisibleContentPosition.disabled` toggles from `true` to `false` (e.g. when `shouldMaintainVisibleContentPosition` changes based on scroll offset). Root cause: `ScrollAnchor` was conditionally rendered based on `shouldMaintainVisibleContentPosition()`. When MVCP was disabled, the anchor unmounted, which made the native Fabric `_firstVisibleView` weak-ref become nil. When MVCP was re-enabled, the anchor remounted at `top: 1,000,000` (its initial position), but `_prevFirstVisibleFrame` was stale at `1,000,000 + X` from the prior anchor instance. `_adjustForMaintainVisibleContentPosition` then computed `deltaY = 0 - (1,000,000 + X)` — a massive negative offset — causing the list to jump to the start. The fix decouples anchor lifetime from the `disabled` flag: `ScrollAnchor` is now always mounted (and `maintainVisibleContentPositionInternal` always non-null) whenever `maintainVisibleContentPosition` prop is defined. The `disabled` flag continues to gate JS-level `scrollBy` corrections in `applyOffsetCorrection` (via `shouldMaintainVisibleContentPosition()`), so the anchor stays in place when MVCP is logically off — the native side always has a live `_firstVisibleView` and a fresh `_prevFirstVisibleFrame` to diff against. -- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/33725 -- PR introducing patch: https://github.com/Expensify/App/pull/88923 - -### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch) - -- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms. -- Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/89768 -- PR introducing patch: https://github.com/Expensify/App/pull/90218 - -### [@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch) - -- Reason: Prevents stale `ViewHolder.onLayout` callbacks from crashing FlashList after the list data/layout table has changed. `validateItemSize` previously read the stored layout with `recyclerViewManager.getLayout(index)`, which throws when the callback's render-time index is no longer present in the layout manager. The patch uses `recyclerViewManager.tryGetLayout(index)` and returns early when the layout is missing, so obsolete measurements are ignored while current indexes continue through the existing width/height comparison. -- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2291 -- E/App issue: https://github.com/Expensify/App/issues/89933 -- PR introducing patch: https://github.com/Expensify/App/pull/91248 - -### [@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch](@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch) - -- Reason: Fixes a "Maximum update depth exceeded" infinite render loop on web (mostly Windows with fractional display scaling). `roundOffPixel` on web was a no-op, so subpixel drift in the child container's `getBoundingClientRect()` width re-triggered `ViewHolderCollection`'s `[fixedContainerSize]` layout effect on every measurement. The patch implements `roundOffPixel` to snap to the device-pixel grid (`Math.round(value * devicePixelRatio) / devicePixelRatio`), matching native `PixelRatio.roundToNearestPixel`. Two measurements that paint the same physical pixel now collapse to the same JS value, breaking the loop. -- Files changed: `dist/recyclerview/utils/measureLayout.web.js` only. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/91584 -- Sentry: https://expensify.sentry.io/issues/APP-DQ2 -- PR introducing patch: https://github.com/Expensify/App/pull/91799 - -### [@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch) - -- Reason: Fixes scrambled DOM order in virtualized list items on web. FlashList uses `position: absolute` to position items, so visual order is determined by CSS `top`/`left` values rather than DOM order. Due to recycling (reusing ViewHolder components for different data items), the DOM order reflects Map insertion order rather than data index order. This causes three web-specific issues: - - 1. **Screen reader reading order**: Assistive technologies follow DOM order, so items are read in a scrambled sequence that doesn't match the visual layout. - 2. **Keyboard Tab navigation**: Tab key follows DOM order, so focus jumps unpredictably between items instead of following the visual top-to-bottom sequence. - 3. **Cross-item text selection**: Selecting text across multiple list items selects them in DOM order rather than visual order, producing garbled selections. - - **How it works:** - - 1. **Stable render order during scroll**: Render entries are maintained in a ref (`renderEntriesRef`) that preserves its order across renders. On each render, a reconcile step removes keys that left the render stack and appends new keys. Because FlashList's recycling mutates index values in place on shared object references (`keyInfo.index = newIndex`), the entries in the ref always have current index values without needing updates — only the array order can be stale. This means during normal scrolling, React sees children in the same order and produces zero `insertBefore` calls, avoiding any DOM reordering. - - 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a single-slot `setTimeout` (armed by `schedulePendingSort`, with the handle held inside `useDeferredCallback`) sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. When the timer fires, it re-checks scroll state via `isScrolling()` — if any scroll is still in progress (a freshly started mousewheel, a continued momentum scroll, etc.), the timer reschedules itself rather than committing, so a long-running scroll never lets a stale timer fire in the middle of motion. The sort uses a separate, sort-only re-render trigger (`bumpSortVersion` from a `useReducer` counter) instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. - - 3. **Focus-aware sort triggering**: Tab navigation walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container resolves which logical row received focus by reading a `data-flashlist-index` DOM marker that each `ViewHolder` renders alongside its children, and routes real focus changes to `maybeDoSortOnFocus`. Spurious refocus events caused by recycling and React's mutation-phase selection-preservation are filtered out so they don't trigger a sort cascade — see [viewholder-marker-and-focus-filter.md](viewholder-marker-and-focus-filter.md) for the full filter design. Tab itself doesn't scroll, but tabbing to a row that's outside the viewport makes the browser auto-scroll to bring it into view; that scroll re-renders the list and runs a separate `maybeDoSortOnScroll` callback. The actual synchronous sort during Tab navigation happens in the scroll callback (see #4); the focus callback typically just schedules a deferred sort. - - 4. **Two `maybeDoSort` callbacks + programmatic-scroll gating**: The focus path and the scroll path have different decisions to make, so the original single `maybeDoSort` is split into two callbacks that cooperate via a one-shot flag (`shouldSortOnNextFocusRef`): - - - **`maybeDoSortOnScroll`** runs from the effect that fires on `renderStack` / `renderId` changes — i.e. whenever recycling produced a new layout. It arms `shouldSortOnNextFocusRef`, evicts any pending-sort timer (a stale timer from a previous scroll's drain cannot fire mid-motion during rapid arrow-key repeats), then picks one of three branches: - - *Programmatic scroll queued or in flight* (`isScrollingProgrammatically()` is true): hand off via `runAfterProgrammaticScroll` → `schedulePendingSort`. Once the scroll settles we still wait an additional `SORT_DELAY_MS` for queued pointer/focus events to land before committing. The flag stays armed. - - *In-motion scroll caused by a recent focus* (`isScrolling()` is true and the last `scroll` event landed within `FOCUS_INDUCED_SCROLL_WINDOW_MS` = 30 ms after the last `focusin`): call `sortItems` synchronously and reset the flag. This is the browser's auto-scroll-into-view from a Tab/focus on an off-viewport row — keeping DOM order synced is critical for the next Tab to land on the right row, even at the cost of perturbing the auto-scroll. **This is the path that does the sync sort during Tab navigation.** - - *Anything else* (user mousewheel/scrollbar/touch, or a quiet list): schedule the deferred sort. The flag stays armed for the next focusin to consume. - - - **`maybeDoSortOnFocus`** runs from the `focusin` listener. It evicts any pending-sort timer; if `shouldSortOnNextFocusRef` is armed it consumes the flag and commits `sortItems` synchronously; either way it then schedules a fresh deferred sort. In the common Tab → auto-scroll flow, `maybeDoSortOnScroll`'s focus-induced branch has already done the sync sort and reset the flag *before* the next focusin gets here, so the sync-sort path inside this callback is mainly a safety net for scroll-less re-renders and for the programmatic-scroll branch (where the flag was armed but no sync sort fired). - - The deferred-sort timer is provided by `useDeferredCallback`, a small inline hook that wraps a single-slot `setTimeout` with a fire-time `shouldDefer` predicate. When the timer expires it re-checks `isScrolling()` and reschedules itself if a scroll is still in progress, so a long-running scroll never lets a stale timer fire in the middle of motion. The "scroll has truly ended" signal driving the programmatic-defer drain is FlashList's existing `isMomentumEnd`, fired by `VelocityTracker` ~100 ms after the last `scroll` event — distance-independent and naturally overlap-safe (the browser merges overlapping smooth scrolls into one). - - 5. **Pre-scroll announcement (`announceProgrammaticScroll`)**: A new public method on `FlashListRef` lets the consumer announce an imminent programmatic scroll *before* `scrollToIndex` is actually called. It flips an "is queued" ref that `isScrollingProgrammatically()` already ORs in, so any sort triggered by an intervening event (notably the `focusin` that fires when the consumer focuses the target row first and only then calls `scrollToIndex`) is correctly held off rather than committing immediately and cancelling the upcoming smooth scroll. The queued flag is handed off to the in-flight ref at `scrollToIndex` entry and finally cleared when the scroll settles, so it cannot get stuck on. - - **Why the deferred approach is necessary:** - - Two distinct web-only hazards make immediate, mid-scroll DOM reordering wrong: - - 1. **Hover/pointer state loss**: When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. Keeping the array order stable during scrolling and only committing after the list goes idle gives the browser time to drain those events before any reorder. - - 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The programmatic-scroll gating in both `maybeDoSort*` callbacks keeps commits out of the smooth-scroll window, so a `scrollToIndex` animation lands only after it has truly ended (`isMomentumEnd`). Browser auto-scroll-into-view triggered by Tab focusing an off-viewport row is intentionally *not* gated this way (see #4 above) — Tab-navigation correctness takes priority over preserving that auto-scroll's centring. - - **Platform gating:** - - On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener (filtered via the `data-flashlist-index` marker) routes real focus changes through `maybeDoSortOnFocus`, and `maybeDoSortOnScroll` decides per-render whether to sort synchronously, defer until momentum-end, or defer the standard `SORT_DELAY_MS`. The deferred path itself reschedules until any scroll has settled, via `useDeferredCallback`'s timer-fire `isScrolling()` re-check. - On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. The marker JSX, the focusin listener, and both `maybeDoSort*` callbacks are gated to web only. - -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/1955 -- E/App issue: https://github.com/Expensify/App/issues/86126 -- PR introducing patch: https://github.com/Expensify/App/pull/85825 - -### [@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch) - -- Reason: Fixes a "Maximum update depth exceeded" (#185) infinite render loop on web with classic (non-overlay) scrollbars — i.e. Windows/Linux Chrome and macOS with "Always show scroll bars". - - A vertical list gets its width from `firstChildViewLayout.width`, the scroll viewport's **client** width, which leaves out the scrollbar. So every time the scrollbar shows or hides, the width changes by about 15px. That relayouts, which changes the content height, which toggles the scrollbar again, and it never settles. - - This only happens on lists that get **shorter as they get narrower**. If narrowing made them taller, the scrollbar would stay put after one toggle and the layout would settle on its own. That matters for how the lock is released below. - - `LinearLayoutManager.updateLayoutParams` sends the measured size through `settleScrollbarOscillation` (web only, since native scrollbars overlay the content). We only call it a flicker when all of these are true, so a real resize is never mistaken for one: - - 1. **Two distinct values** in the last 8 rounded sizes. A drag passes through many widths, and rounding absorbs subpixel drift. - 2. **At least 3 flips** between them. One toggle only flips twice. We keep 8 samples rather than 4 because the toggle can lag a frame, so the bounce is often `A,A,B,B`. - 3. **A scrollbar-sized gap**, at most 25px. Classic scrollbars are ~15-17px. - 4. **The current frame is on the smaller value**, so we lock to the width that's actually on screen. - - Then we lock `boundedSize` to the smaller value, which already leaves room for the scrollbar, so rows never overflow. - - **Releasing.** Once we lock, the scrollbar disappears and the next frame measures the wider width again. Releasing as soon as we see it doesn't end the loop, it just makes each round slower. So: - - - The **smaller** value keeps the lock. - - The **larger** value gives the real width back `MAX_LOCK_RELEASE_CYCLES` (1) time, then the lock holds. The counter isn't reset when the same pair locks again, or a flicker would top it up forever. - - Anything **outside the pair** is a real resize, so release and reset. - - With the lock held, `boundedSize` stops changing, `recomputeLayouts` stops running and the re-renders stop. The trade-off: the flicker usually uses up the one allowed release, so the width stays put until the next real resize. A list that later stops needing a scrollbar keeps about 15px of empty space on the right. -- Files changed: `dist/recyclerview/layout-managers/LinearLayoutManager.js` only. -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2334 -- E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263, https://github.com/Expensify/App/issues/95719 -- PR introducing patch: https://github.com/Expensify/App/pull/92520 (hardened for #95719) - -### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch) - -- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`). Six changes: - 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. - 2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands. - 3. **Bottom crop** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged up so the current bottom item is cropped by a few pixels — signaling there is more content below. - 4. **`recomputeLayouts` range** in `applyInitialScrollAdjustment` (`RecyclerViewManager.js`): the recompute that precedes reading the target offset is widened from `recomputeLayouts(0, initialScrollIndex)` to `recomputeLayouts(0, this.getDataLength() - 1)`, so every item gets a measured/re-estimated layout before the positioning. - 5. **Deferred re-scroll reads the latest offset** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): the `setTimeout(0)` re-scroll used to close over the `offset` from its own commit. When a later commit recomputed a newer offset before that timeout fired, the stale timeout snapped the list back to the outdated offset — a visible jump. The offset is now stored in `latestInitialScrollOffsetRef` and read at fire-time, so any pending re-scroll targets the current offset instead of a stale one. - 6. **Progressive render covers the drawDistance buffer** in `renderProgressively` (`RecyclerViewManager.js`): with an explicit `initialScrollIndex`, the drawDistance buffer used to mount right after first paint; its measurements re-estimated every still-unmeasured item before the target, which could collapse the content height below the applied scroll offset and make the native ScrollView clamp. Now the progressive-render phase also waits for the buffer around the viewport to be measured, so the layout converges before anything is painted. Applies only to a **positive** `initialScrollIndex`. The `-1` sentinel that the selection lists synthesize for "nothing focused" scrolls nowhere, and index `0` offsets by at most the `ListHeaderComponent` height, so both keep stock progressive render (they used to trip this branch, because `-1 !== undefined`). -- Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`. -- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2318 (for point 4) -- E/App issue: https://github.com/Expensify/App/issues/92152 -- PR introducing patch: https://github.com/Expensify/App/pull/93403 - -### [@shopify+flash-list+2.3.0+014+external-window-size.patch](@shopify+flash-list+2.3.0+014+external-window-size.patch) - -- Reason: Adds an **`overrideWindowSize`** prop that lets a list declare its visible window (`{width, height}`) instead of deriving it from `measureParentSize(internalViewRef)`. Needed for an *externally-driven* list — one whose `renderScrollComponent` is a non-scrolling `View` that grows to the full content height and receives synthetic scroll events from a parent scroller. Without this, FlashList measures the outer container (as tall as all content) as its viewport and renders every row, defeating virtualization. The change is minimal: `measureParentSize` is still assigned to `outerViewSize` and used for `containerViewSizeRef` (layout-change detection) and the 0×0 hidden-guard from patch 002; only the `windowSize` fed to `updateLayoutParams` is `overrideWindowSize ?? outerViewSize`. Fully backward compatible — when the prop is unset, `windowSize === outerViewSize` and behavior is byte-identical. Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table (`ExternalScrollFlashListTable`), which windows its rows against the unified list's vertical scroll. -- Files changed: `src/FlashListProps.ts`, `src/recyclerview/RecyclerView.tsx`, `dist/FlashListProps.d.ts`, `dist/recyclerview/RecyclerView.js`. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/91425 -- PR introducing patch: https://github.com/Expensify/App/pull/91422 - -### [@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch) - -- Reason: Makes `maintainVisibleContentPosition` aware of the `ListHeaderComponent`. Item layouts are header-relative, so MVCP was blind to the header in two symmetric ways: - 1. **Header resize was never corrected**: when the header changes height after layout (e.g. a nested virtualized table settling from estimated to measured row heights, ~400px on a 207-row table), every data item shifts on screen but no tracked `x`/`y` changes — the anchored item (e.g. a deep-linked report action positioned via `initialScrollIndex`) drifts out of the viewport with no correction. Fixed by capturing `firstItemOffset` alongside the anchor layout (`firstVisibleItemFirstItemOffset`) and including its delta in the correction diff. On the `ScrollAnchor.scrollBy` path (iOS/Android) the full diff is applied; on the `scrollTo` fallback paths (web, Android inverted) only the layout diff is added, because `getAbsoluteLastScrollOffset()` already reflects the current `firstItemOffset` (the tracker's relative offset only resyncs on scroll events), so the header delta is already embedded in it. - 2. **Wrong anchor while viewing the header**: `computeFirstVisibleIndexForOffsetCorrection` clamps the anchor to `Math.max(0, startIndex)`, so with the viewport over the header it tracked item 0 — possibly far below the fold — and "maintained" that off-screen item's position through data prepends/header growth, yanking the viewport away from the header (report opening at the top visibly jumped down to the chat). Fixed by dropping the anchor (`firstVisibleItemKey = undefined`) whenever the absolute scroll offset is smaller than `firstItemOffset`: the header is the user's visual anchor then, and its top never moves, so the correct correction is none. - Additionally, `RecyclerView` now observes the header wrapper's `onLayout` (main-axis size only, skipped for inverted lists where `firstItemOffset` is forced to 0) and triggers `recyclerViewContext.layout()` on change. Without this, a header that resizes from a commit inside its own subtree (the nested table settling) never causes the parent list to re-measure `firstItemOffset`, so items keep stale positions and `applyOffsetCorrection` never sees the delta. - Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table, where the whole table is the unified list's `ListHeaderComponent`: deep links into the report actions below the table now stay anchored while the table settles, and MVCP could be re-enabled for that mode (the `{disabled: true}` workaround is removed). -- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/hooks/useRecyclerViewController.tsx`, `src/recyclerview/hooks/useSecondaryProps.tsx`, and their `dist` counterparts. -- Upstream PR/issue: TBD -- E/App issue: https://github.com/Expensify/App/issues/91425 -- PR introducing patch: https://github.com/Expensify/App/pull/91422 - -### [@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch](@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch) - -- Reason: Prevents an `index out of bounds, not enough layouts` crash thrown while `ViewHolderCollection` renders. This is the render-path sibling of patch `009`, which only guarded the `validateItemSize` measurement callback. The crash originates in upstream flash-list and reproduces on **every platform** (native crash: `APP-8PG`), not just web. The render stack (`RenderStackManager.keyMap`, returned by `RecyclerViewManager.getRenderStack()`) can hold an entry whose stored `index` exceeds the current `layouts` length when the list `data` shrinks between renders (e.g. deleting a report action, IOU actions being filtered once transactions load, or a Concierge draft being removed). This is a timing gap inside flash-list's own update pipeline: on a data shrink `LayoutManager.modifyLayout` truncates `this.layouts` synchronously (`getLayoutCount()` drops immediately), but the render stack is pruned of the now-out-of-bounds keys only later, when `RenderStackManager.sync()` runs. Any render committed in that gap iterates a `keyMap` still carrying a pre-shrink `index` against the already-shortened `layouts`, so the unguarded `getLayout(index)` wired at `RecyclerView` → `LayoutManager.getLayout` throws. Upstream already guards this same staleness on the measurement path — `modifyLayout` filters stale `layoutInfo` with the comment _"layoutInfo may contain stale indices from ViewHolders that were rendered before the data shrunk"_ — but left the render path unguarded. The patch wires `ViewHolderCollection`'s `getLayout` prop to the bounds-safe `recyclerViewManager.tryGetLayout(index)` and skips (returns `null` for) any render entry whose layout is `undefined`, so a stale index is dropped for that render instead of crashing. Because `keyMap`/`LayoutManager` are shared, platform-agnostic state, the guard applies on both render branches — web's `renderEntriesRef.current.map` and native's `Array.from(renderStack.entries())`. Patch `011` (which introduces web's `renderEntriesRef` copy) only carries the index forward; it is not the source of the stale index. -- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/ViewHolderCollection.tsx`, and their `dist` counterparts (`dist/recyclerview/RecyclerView.js`, `dist/recyclerview/ViewHolderCollection.js`, `dist/recyclerview/ViewHolderCollection.d.ts`). -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2440 -- E/App issue: https://github.com/Expensify/App/issues/97472 -- Sentry: https://expensify.sentry.io/issues/APP-8PG -- PR introducing patch: https://github.com/Expensify/App/pull/98015 diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index 59f8dab0210e..3eadb9ad3ec2 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -10,12 +10,12 @@ import type {EmojiPickerList, EmojiPickerListItem, HeaderIndices} from '@libs/Em import CONST from '@src/CONST'; -import type {FlashListRef, ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {ForwardedRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {FlashList} from '@shopify/flash-list'; +import {LegendList} from '@legendapp/list/react-native'; import React from 'react'; import {View} from 'react-native'; @@ -33,7 +33,7 @@ type BaseEmojiPickerMenuProps = { listWrapperStyle?: StyleProp; data: EmojiPickerList; - renderItem: ListRenderItem; + renderItem: NonNullable['renderItem']>; extraData?: Array | ((skinTone: number) => void)>; stickyHeaderIndices?: number[]; alwaysBounceVertical?: boolean; @@ -42,11 +42,11 @@ type BaseEmojiPickerMenuProps = { /** The current search input value, used for accessibility re-announcements */ searchValue?: string; - ref?: ForwardedRef>; + ref?: ForwardedRef; }; /** - * Improves FlashList's recycling when there are different types of items + * Improves LegendList's recycling when there are different types of items */ const getItemType = (item: EmojiPickerListItem): string | undefined => { // item is undefined only when list is empty @@ -116,7 +116,8 @@ function BaseEmojiPickerMenu({ /> )} - } alwaysBounceVertical={alwaysBounceVertical} contentContainerStyle={styles.ph4} - extraData={extraData} + extraData={[extraData, renderItem]} getItemType={getItemType} onMomentumScrollEnd={onMomentumScrollEnd} - overrideProps={{ - // scrollPaddingTop set to consider sticky header while scrolling, https://github.com/Expensify/App/issues/36883 - style: { - minHeight: 1, - minWidth: 1, - scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT, - }, + style={{ + minHeight: 1, + minWidth: 1, + // Keep keyboard scrolling below the sticky category header. + scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT, }} scrollEnabled={data.length > 0} /> diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 40eee6a9aaea..c4f2a795fbe1 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -19,7 +19,7 @@ import {getRemovedSkinToneEmoji} from '@libs/EmojiUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useMemo, useRef, useState} from 'react'; @@ -145,8 +145,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro * Items with the code "SPACER" return nothing and are used to fill rows up to 8 * so that the sticky headers function properly. */ - const renderItem: ListRenderItem = useCallback( - ({item, target, index}) => { + const renderItem: NonNullable['renderItem']> = useCallback( + ({item, index}) => { const code = item.code; const types = 'types' in item ? item.types : undefined; @@ -161,7 +161,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro accessible accessibilityRole="header" accessibilityLabel={translate(`emojiPicker.headers.${code}` as TranslationPaths)} - style={[styles.emojiHeaderContainer, target === 'StickyHeader' ? styles.mh4 : {width: windowWidth}]} + style={[styles.emojiHeaderContainer, {width: windowWidth}]} onLayout={() => handleHeaderLayout(index)} > {translate(`emojiPicker.headers.${code}` as TranslationPaths)} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 49fb15ae2b3f..4568f08c3846 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -24,7 +24,7 @@ import {shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import throttle from 'lodash/throttle'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -320,8 +320,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro * so that the sticky headers function properly. * */ - const renderItem: ListRenderItem = useCallback( - ({item, index, target}) => { + const renderItem: NonNullable['renderItem']> = useCallback( + ({item, index}) => { const code = item.code; const types = 'types' in item ? item.types : undefined; @@ -336,11 +336,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro tabIndex={-1} role={CONST.ROLE.HEADING} onLayout={() => handleHeaderLayout(index)} - style={[ - styles.emojiHeaderContainer, - styles.emojiHeaderContainerWidth(shouldUseNarrowLayout, windowWidth), - target === 'StickyHeader' ? styles.stickyHeaderEmoji : undefined, - ]} + style={[styles.emojiHeaderContainer, styles.emojiHeaderContainerWidth(shouldUseNarrowLayout, windowWidth)]} > {translate(`emojiPicker.headers.${code}` as TranslationPaths)} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts index cb3c2901ca9a..a6be02db18c5 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts +++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts @@ -8,19 +8,19 @@ import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import type {EmojiPickerList, EmojiPickerListItem} from '@libs/EmojiUtils'; +import type {EmojiPickerList} from '@libs/EmojiUtils'; import {getHeaderEmojis, getSpacersIndexes, mergeEmojisWithFrequentlyUsedEmojis, processFrequentlyUsedEmojis, suggestEmojis} from '@libs/EmojiUtils'; import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; import ONYXKEYS from '@src/ONYXKEYS'; import calculateModalHeightInLandscapeMode from '@src/utils/calculateModalHeightInLandscapeMode'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; const useEmojiPickerMenu = () => { - const emojiListRef = useRef>(null); + const emojiListRef = useRef(null); const [frequentlyUsedEmojis] = useOnyx(ONYXKEYS.FREQUENTLY_USED_EMOJIS); const allEmojis = useMemo(() => mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis)), [frequentlyUsedEmojis]); diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx deleted file mode 100644 index f9a7b9528735..000000000000 --- a/src/components/FlashList/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; - -import type {FlashListProps} from '@shopify/flash-list'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; - -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 handleScroll = (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll - emitComposerScrollEvents(); - }; - - return ( - - {...restProps} - inverted={inverted} - onScroll={handleScroll} - /> - ); -} - -export default FlashList; 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/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index e19e60f80ef5..173afcd9ba73 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,3 +1,4 @@ +import setLegendListItemZIndex from '@components/LegendList/setLegendListItemZIndex'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useNetwork from '@hooks/useNetwork'; @@ -15,11 +16,11 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; -import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {ReactElement} from 'react'; +import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -27,7 +28,6 @@ import type {LHNOptionsListProps, RenderItemProps} from './types'; import LHNTooltipContextProvider from './LHNTooltipContextProvider'; import OptionRowLHNData from './OptionRowLHN'; -import OptionRowRendererComponent from './OptionRowRendererComponent'; const keyExtractor = (item: Report) => `report_${item.reportID}`; const platform = getPlatform(); @@ -36,7 +36,7 @@ const isWeb = platform === CONST.PLATFORM.WEB; function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optionMode, shouldDisableFocusOptions = false, onFirstItemRendered = () => {}}: LHNOptionsListProps) { const {saveScrollOffset, getScrollOffset, saveScrollIndex, getScrollIndex} = useContext(ScrollOffsetContext); const {isOffline} = useNetwork(); - const flashListRef = useRef>(null); + const legendListRef = useRef(null); const route = useRoute(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const reportAttributes = useReportAttributes(); @@ -57,6 +57,48 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio onFirstItemRendered(); }, [onFirstItemRendered]); + const updateItemZIndex = useCallback((index: number) => { + if (isWeb) { + return; + } + + setLegendListItemZIndex(legendListRef.current, index, -index); + }, []); + + const updateMountedItemZIndices = useCallback(() => { + if (isWeb || !legendListRef.current) { + return; + } + + const state = legendListRef.current.getState(); + const startIndex = Math.max(0, state.startBuffered); + const endIndex = Math.min(state.data.length - 1, state.endBuffered); + if (!Number.isFinite(startIndex) || !Number.isFinite(endIndex) || endIndex < startIndex) { + return; + } + + for (let index = startIndex; index <= endIndex; index++) { + updateItemZIndex(index); + } + }, [updateItemZIndex]); + + const handleItemLayout = useCallback( + (index: number) => { + onLayoutItem(); + updateItemZIndex(index); + }, + [onLayoutItem, updateItemZIndex], + ); + + const onViewableItemsChanged = useCallback['onViewableItemsChanged']>>( + ({viewableItems}) => { + for (const item of viewableItems) { + updateItemZIndex(item.index); + } + }, + [updateItemZIndex], + ); + // Controls the visibility of the educational tooltip based on user scrolling. // Hides the tooltip when the user is scrolling and displays it once scrolling stops. const triggerScrollEvent = useScrollEventEmitter(); @@ -97,33 +139,42 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio viewMode={optionMode} isOptionFocused={!shouldDisableFocusOptions} onSelectRow={onSelectRow} - onLayout={onLayoutItem} + onLayout={() => handleItemLayout(index)} testID={index} /> ); }, - [reportAttributes, reports, policy, personalDetails, optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem], + [reportAttributes, reports, policy, personalDetails, optionMode, shouldDisableFocusOptions, onSelectRow, handleItemLayout], ); const extraData = useMemo( - () => [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], - [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline], + () => [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline, renderItem], + [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline, renderItem], ); const previousOptionMode = usePrevious(optionMode); useEffect(() => { - if (previousOptionMode === null || previousOptionMode === optionMode || !flashListRef.current) { + if (isWeb) { + return; + } + + const animationFrame = requestAnimationFrame(updateMountedItemZIndices); + return () => cancelAnimationFrame(animationFrame); + }, [data, updateMountedItemZIndices]); + + useEffect(() => { + if (previousOptionMode === null || previousOptionMode === optionMode || !legendListRef.current) { return; } // If the option mode changes want to scroll to the top of the list because rendered items will have different height. - flashListRef.current.scrollToOffset({offset: 0}); + legendListRef.current.scrollToOffset({offset: 0}); }, [previousOptionMode, optionMode]); - const onScroll = useCallback['onScroll']>>( + const onScroll = useCallback['onScroll']>>( (e) => { - // If the layout measurement is 0, it means the FlashList is not displayed but the onScroll may be triggered with offset value 0. + // If the layout measurement is 0, it means the LegendList is not displayed but the onScroll may be triggered with offset value 0. // We should ignore this case. if (e.nativeEvent.layoutMeasurement.height === 0) { return; @@ -140,16 +191,16 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const onLayout = useCallback(() => { const offset = getScrollOffset(route); - if (!(offset && flashListRef.current) || isWeb) { + if (!(offset && legendListRef.current) || isWeb) { return; } // We need to use requestAnimationFrame to make sure it will scroll properly on iOS. requestAnimationFrame(() => { - if (!(offset && flashListRef.current)) { + if (!(offset && legendListRef.current)) { return; } - flashListRef.current.scrollToOffset({offset}); + legendListRef.current.scrollToOffset({offset}); }); }, [getScrollOffset, route]); @@ -159,11 +210,10 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio return ( - diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx deleted file mode 100644 index 9c9fba6d5f61..000000000000 --- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type {StyleProp, ViewStyle} from 'react-native'; - -import {View} from 'react-native'; - -type OptionRowRendererComponentProps = { - /** The index position of this option row in the list */ - index: number; - - onLayout?: () => void; - - /** Style prop for customizing the option row */ - style?: StyleProp; -}; - -function OptionRowRendererComponent({...props}: OptionRowRendererComponentProps) { - return ( - - ); -} - -export default OptionRowRendererComponent; diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx deleted file mode 100644 index 25afb0124e9f..000000000000 --- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -const OptionRowRendererComponent = undefined; - -export default OptionRowRendererComponent; diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx deleted file mode 100644 index a209e7fd0c03..000000000000 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import ScrollView from '@components/ScrollView'; - -import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; - -// Deliberately not the @components/FlashList wrapper: it doesn't type `ref` (this list needs one for layout reads), -// and its only addition — composer scroll-event emission — would fire duplicates here, since this list's scroll events -// are synthesized from the parent list's scroll, which already emits them. -import {FlashList} from '@shopify/flash-list'; -import React, {useEffect, useImperativeHandle, useRef} from 'react'; -import {View} from 'react-native'; - -/** - * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll - * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches - * the driver, which turns it into a synthetic scroll event that updates only the nested FlashList's render stack. - */ -type ScrollOffsetStore = { - getOffset: () => number; - setOffset: (offset: number) => void; - subscribe: (listener: () => void) => () => void; -}; - -function createScrollOffsetStore(): ScrollOffsetStore { - let offset = 0; - const listeners = new Set<() => void>(); - return { - getOffset: () => offset, - setOffset: (next: number) => { - if (next === offset) { - return; - } - offset = next; - for (const listener of listeners) { - listener(); - } - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; -} - -// The subset of the ScrollView imperative surface FlashList actually drives — `getScrollableNode` is read internally -// by RecyclerView for bound detection, and `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` back -// FlashList's public ref. Naming it makes "the part of ScrollView the driver must honor" explicit rather than erased. -type MinimalScrollRef = { - scrollTo: () => void; - scrollToEnd: () => void; - flashScrollIndicators: () => void; - getScrollableNode: () => View | null; - getNativeScrollRef: () => View | null; -}; - -// `store` and `offsetTop` are injected at runtime by FlashList via `overrideProps`, never by FlashList's own typed -// call site — declaring them optional makes the driver structurally a ScrollView component, so no cast is needed to -// pass it as `renderScrollComponent`. FlashList's renderScrollComponent wrapper passes the ref as a prop, so the -// driver takes `ref` directly (React 19 style) rather than via forwardRef. -type ExternalScrollDriverProps = Omit & { - /** Source of the parent list's vertical scroll offset. */ - store?: ScrollOffsetStore; - - /** Where the table region starts within the parent page's scrollable content (px from the top). */ - offsetTop?: number; - - /** Imperative handle FlashList drives (scrollTo/scrollToEnd/getScrollableNode…). */ - ref?: React.Ref; -}; - -/** - * Replacement scroll container for the nested table FlashList. It does NOT scroll — it is a plain View that grows to - * the full content height so the parent page scrolls through it — and it synthesizes FlashList's vertical scroll - * events from the parent's offset. FlashList reads `getScrollableNode` internally and delegates its public - * `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` to this ref; the scroll no-ops mean offset - * corrections settle below the fold exactly like the parent-driven windowing. Must be a stable module-level component: - * FlashList memoizes its scroll component on identity. - */ -function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, ref}: ExternalScrollDriverProps) { - const nodeRef = useRef(null); - - useImperativeHandle( - ref, - () => ({ - scrollTo: () => {}, - scrollToEnd: () => {}, - flashScrollIndicators: () => {}, - getScrollableNode: () => nodeRef.current, - getNativeScrollRef: () => nodeRef.current, - }), - [], - ); - - useEffect(() => { - if (!store) { - return; - } - const emit = () => { - // Offset into the nested list's own coordinate space (FlashList subtracts its measured firstItemOffset — - // the header height — internally). Only `contentOffset.y` is read for a vertical list, so nothing else is - // populated; windowing comes from the `overrideWindowSize` prop, not this event. - const y = Math.max(0, store.getOffset() - offsetTop); - try { - // Synthesizing a native scroll event requires an assertion: NativeSyntheticEvent's target/currentTarget - // are RN HostInstances that can't be constructed in JS. FlashList's vertical handler reads only - // contentOffset.y. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent); - } catch { - // During back-navigation teardown FlashList can be driven after its layout manager is gone, which - // throws ("LayoutManager is not initialized"). This call only feeds a synthetic scroll frame to - // window the nested list, so a dropped frame here is harmless — swallow it rather than crash. - } - }; - // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the - // measured offset, or FlashList's scroll handler change — none of which happen per scroll frame. - emit(); - return store.subscribe(emit); - }, [store, offsetTop, onScroll]); - - return ( - - {children} - - ); -} - -type ExternalScrollFlashListTableHandle = { - /** Page-space position of a row — where it sits within the parent's scrollable content. Derived from the nested - * list's layout data, so it works for rows that aren't mounted. The table owns this math (offsetTop + its own - * header height + the row's layout) so the parent never learns the nested coordinate system. */ - getRowPageOffset: (index: number) => {top: number; height: number} | undefined; -}; - -type ExternalScrollFlashListTableProps = { - /** Rows to render. FlashList windows and recycles them against the parent's scroll offset. */ - items: T[]; - - /** Stable key per row. */ - keyExtractor: (item: T, index: number) => string; - - /** Recycling bucket per row (transaction vs. group header) so FlashList reuses like with like. */ - getItemType: (item: T) => string; - - /** Renders a single row. */ - renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; - - /** Column header rendered above the rows and scrolled horizontally with them. */ - renderHeader: () => React.ReactElement | null; - - /** Estimated row height used before a row has been measured. */ - estimatedRowHeight: number; - - /** Full table width (wider than the viewport). Drives the horizontal scroll range. */ - contentWidth: number; - - /** Shared offset store fed by the parent's onScroll. */ - store: ScrollOffsetStore; - - /** Visible height of the parent viewport. */ - viewportHeight: number; - - /** Where the table region starts within the parent page's scrollable content (px from the top). */ - offsetTop: number; - - /** Imperative handle exposing row positions in page space (see ExternalScrollFlashListTableHandle). */ - ref?: React.Ref; -}; - -/** - * A vertically-virtualized, horizontally-scrollable table built on FlashList instead of a hand-rolled virtualized list. - * - * The nested FlashList is fed a non-scrolling `ExternalScrollDriver` as its scroll container, so it grows to full - * content height (the parent page scrolls through it) while FlashList still recycles rows against the parent's scroll - * offset — via the patched `overrideWindowSize` prop, which lets FlashList treat the parent viewport as its window - * instead of measuring its own (full-height) container. A single native horizontal ScrollView wraps the whole list, so - * all rows share one smooth horizontal scroll and nothing outside it moves sideways. - * - * LAYOUT NOTE: FlashList's outer container defaults to `flex: 1` + `overflow: hidden` (a clipping viewport). We - * override it via `style` below to grow to content height and not clip, since the page — not the list — owns vertical - * scroll. - */ -function ExternalScrollFlashListTable({ - items, - keyExtractor, - getItemType, - renderItem, - renderHeader, - estimatedRowHeight, - contentWidth, - store, - viewportHeight, - offsetTop, - ref, -}: ExternalScrollFlashListTableProps) { - const lastIndex = items.length - 1; - const listRef = useRef>(null); - - useImperativeHandle( - ref, - () => ({ - getRowPageOffset: (index: number) => { - const layout = listRef.current?.getLayout(index); - if (!layout) { - return undefined; - } - return {top: offsetTop + (listRef.current?.getFirstItemOffset() ?? 0) + layout.y, height: layout.height}; - }, - }), - [offsetTop], - ); - - return ( - - - ref={listRef} - data={items} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={({item, index}: ListRenderItemInfo) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})} - ListHeaderComponent={renderHeader()} - drawDistance={estimatedRowHeight * 12} - renderScrollComponent={ExternalScrollDriver} - // Consumed by ExternalScrollDriver (FlashList spreads overrideProps onto the scroll component). - overrideProps={{store, offsetTop}} - // Treat the parent viewport as the list's window instead of measuring the (full-height) driver View. - overrideWindowSize={{width: contentWidth, height: viewportHeight}} - // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own - // clipping viewport must be neutralized. - style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}} - scrollEnabled={false} - /> - - ); -} - -export default ExternalScrollFlashListTable; -export {createScrollOffsetStore}; -export type {ExternalScrollFlashListTableHandle}; diff --git a/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx new file mode 100644 index 000000000000..2dc60dfaab68 --- /dev/null +++ b/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx @@ -0,0 +1,280 @@ +import ScrollView from '@components/ScrollView'; + +import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; + +import {LegendList} from '@legendapp/list/react-native'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; +import {View} from 'react-native'; + +/** + * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll + * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches + * the driver, which turns it into a synthetic scroll event that updates only the nested LegendList's render window. + */ +type ScrollOffsetStore = { + getOffset: () => number; + setOffset: (offset: number) => void; + subscribe: (listener: () => void) => () => void; +}; + +function createScrollOffsetStore(): ScrollOffsetStore { + let offset = 0; + const listeners = new Set<() => void>(); + return { + getOffset: () => offset, + setOffset: (next: number) => { + if (next === offset) { + return; + } + offset = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +type MeasureCallback = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void; + +// The ScrollView methods LegendList reads from its custom scroll component. The driver does not scroll itself, but it +// must report the parent viewport during layout and provide the no-op scrolling methods used by LegendList internals. +type MinimalScrollRef = { + measure: (callback: MeasureCallback) => void; + scrollTo: (options?: {x?: number; y?: number; animated?: boolean}) => void; + scrollToEnd: (options?: {animated?: boolean}) => void; + flashScrollIndicators: () => void; + getScrollableNode: () => View | null; + getNativeScrollRef: () => View | null; + getScrollResponder: () => null; + getCurrentScrollOffset: () => number; +}; + +type ExternalScrollDriverProps = Omit & { + /** Source of the parent list's vertical scroll offset. */ + store: ScrollOffsetStore; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop: number; + + /** Height LegendList must use for its virtualized viewport instead of the full-height driver View. */ + viewportHeight: number; + + /** Imperative handle LegendList drives. */ + ref?: React.Ref; +}; + +/** + * Replacement scroll container for the nested table LegendList. It does not scroll. It is a plain View that grows to + * the full content height so the parent page scrolls through it. Its layout callbacks substitute the parent viewport + * height for the View's real height, giving LegendList a bounded virtualized window without a package patch. + */ +function ExternalScrollDriver({store, offsetTop, viewportHeight, onLayout, onScroll, children, style, testID, ref}: ExternalScrollDriverProps) { + const nodeRef = useRef(null); + const lastLayoutEventRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + measure: (callback) => { + const node = nodeRef.current; + if (!node) { + callback(0, 0, 0, viewportHeight, 0, 0); + return; + } + + node.measure((x, y, width, _height, pageX, pageY) => callback(x, y, width, viewportHeight, pageX, pageY)); + }, + scrollTo: () => {}, + scrollToEnd: () => {}, + flashScrollIndicators: () => {}, + getScrollableNode: () => nodeRef.current, + getNativeScrollRef: () => nodeRef.current, + getScrollResponder: () => null, + getCurrentScrollOffset: () => getLocalScrollOffset(store, offsetTop), + }), + [offsetTop, store, viewportHeight], + ); + + useEffect(() => { + const emit = () => { + // NativeSyntheticEvent's host targets cannot be constructed in JavaScript. LegendList's vertical handler + // reads contentOffset.y and treats the event like a normal parent-driven scroll frame. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + onScroll?.({nativeEvent: {contentOffset: {x: 0, y: getLocalScrollOffset(store, offsetTop)}}} as NativeSyntheticEvent); + }; + emit(); + return store.subscribe(emit); + }, [offsetTop, onScroll, store]); + + useEffect(() => { + const event = lastLayoutEventRef.current; + if (!event) { + return; + } + + onLayout?.(getViewportLayoutEvent(event, viewportHeight)); + }, [onLayout, viewportHeight]); + + const handleLayout = (event: LayoutChangeEvent) => { + lastLayoutEventRef.current = event; + onLayout?.(getViewportLayoutEvent(event, viewportHeight)); + }; + + return ( + + {children} + + ); +} + +function getLocalScrollOffset(store: ScrollOffsetStore, offsetTop: number): number { + return Math.max(0, store.getOffset() - offsetTop); +} + +function getViewportLayoutEvent(event: LayoutChangeEvent, viewportHeight: number): LayoutChangeEvent { + return { + ...event, + nativeEvent: { + ...event.nativeEvent, + layout: {...event.nativeEvent.layout, height: viewportHeight}, + }, + }; +} + +type ExternalScrollLegendListTableHandle = { + /** Page-space position of a row — where it sits within the parent's scrollable content. Derived from the nested + * list's layout data, so it works for rows that aren't mounted. The table owns this math (offsetTop + its own + * header height + the row's layout) so the parent never learns the nested coordinate system. */ + getRowPageOffset: (index: number) => {top: number; height: number} | undefined; +}; + +type ExternalScrollLegendListTableProps = { + /** Rows to render. LegendList windows them against the parent's scroll offset. */ + items: T[]; + + /** Stable key per row. */ + keyExtractor: (item: T, index: number) => string; + + /** Item type per row, used for independent row-size estimates. */ + getItemType: (item: T) => string; + + /** Renders a single row. */ + renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; + + /** Column header rendered above the rows and scrolled horizontally with them. */ + renderHeader: () => React.ReactElement | null; + + /** Estimated row height used before a row has been measured. */ + estimatedRowHeight: number; + + /** Full table width (wider than the viewport). Drives the horizontal scroll range. */ + contentWidth: number; + + /** Shared offset store fed by the parent's onScroll. */ + store: ScrollOffsetStore; + + /** Visible height of the parent viewport. */ + viewportHeight: number; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop: number; + + /** Imperative handle exposing row positions in page space. */ + ref?: React.Ref; +}; + +/** + * A vertically virtualized, horizontally scrollable table driven by its parent LegendList's vertical offset. + * The custom scroll driver grows to content height while reporting the parent's bounded viewport to LegendList. A + * single horizontal ScrollView keeps the column header and rows aligned without moving the chat below it. + */ +function ExternalScrollLegendListTable({ + items, + keyExtractor, + getItemType, + renderItem, + renderHeader, + estimatedRowHeight, + contentWidth, + store, + viewportHeight, + offsetTop, + ref, +}: ExternalScrollLegendListTableProps) { + const lastIndex = items.length - 1; + const listRef = useRef(null); + const headerSizeRef = useRef(0); + + useImperativeHandle( + ref, + () => ({ + getRowPageOffset: (index: number) => { + const state = listRef.current?.getState(); + const rowTop = state?.positionAtIndex(index); + if (rowTop === undefined || !Number.isFinite(rowTop)) { + return undefined; + } + return { + top: offsetTop + headerSizeRef.current + rowTop, + height: state?.sizeAtIndex(index) ?? estimatedRowHeight, + }; + }, + }), + [estimatedRowHeight, offsetTop], + ); + + const renderScrollComponent = (scrollProps: ScrollViewProps) => ( + + ); + + return ( + + + ref={listRef} + data={items} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={({item, index}: LegendListRenderItemProps) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})} + extraData={renderItem} + ListHeaderComponent={renderHeader()} + drawDistance={estimatedRowHeight * 12} + estimatedItemSize={estimatedRowHeight} + estimatedListSize={{width: contentWidth, height: viewportHeight}} + renderScrollComponent={renderScrollComponent} + onMetricsChange={({headerSize}) => { + headerSizeRef.current = headerSize; + }} + // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own + // clipping viewport must be neutralized. + style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}} + scrollEnabled={false} + /> + + ); +} + +export default ExternalScrollLegendListTable; +export {createScrollOffsetStore}; +export type {ExternalScrollLegendListTableHandle}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 74597bda34dd..6b345ac9c619 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,7 +1,6 @@ import LinkButton from '@components/ButtonComposed/composed/LinkButton'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; -import type FlatListRefType from '@components/FlashList/types'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; @@ -63,6 +62,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 variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -104,7 +105,7 @@ type TransactionListItemData = {type: 'section-header'; groupKey: string; group: /** * Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state. * Wide on purpose: this is the single integration point between TransactionList's internal state and the parent - * FlatList that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the + * list that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the * same locals across multiple call sites without earning an abstraction. */ type MoneyRequestReportTransactionListController = { @@ -123,7 +124,7 @@ type MoneyRequestReportTransactionListController = { /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ afterListContent: React.ReactElement | null; - /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollFlashListTable` with its own horizontal scroller. */ + /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollLegendListTable` with its own horizontal scroller. */ shouldScrollHorizontally: boolean; /** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */ @@ -138,7 +139,7 @@ const EMPTY_VIOLATIONS: OnyxTypes.TransactionViolations = []; /** * Looks up violations from the bulk collection and filters them via `getVisibleTransactionViolations`. * Returns the stable EMPTY_VIOLATIONS reference for the common no-violations case so the row's prop - * identity stays stable across FlashList recycles. + * identity stays stable across recycled list rows. */ function filterTransactionViolations( transaction: TransactionWithOptionalHighlight, @@ -202,8 +203,8 @@ type MoneyRequestReportTransactionListProps = { /** Report action ID the unified list should initially scroll to, when deep-linked. */ linkedReportActionID: string | undefined; - /** Ref forwarded to the underlying FlashList. */ - listRef: FlatListRefType; + /** Ref forwarded to the underlying action list. */ + listRef: ActionListRefType; /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ onLastItemIndexChange?: (index: number) => void; @@ -211,28 +212,28 @@ type MoneyRequestReportTransactionListProps = { /** Accessibility label for the unified list. */ accessibilityLabel: string; - /** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */ + /** Action list onLayout callback (distinct from the empty-state `onLayout` above). */ onListLayout: () => void; - /** FlashList onScroll callback. */ + /** Action list onScroll callback. */ onScroll: (event: NativeSyntheticEvent) => void; - /** FlashList onScrollBeginDrag callback. */ + /** Action list onScrollBeginDrag callback. */ onScrollBeginDrag: () => void; - /** FlashList onContentSizeChange callback. */ + /** Action list onContentSizeChange callback. */ onContentSizeChange: () => void; - /** FlashList onViewableItemsChanged callback. */ + /** Action list onViewableItemsChanged callback. */ onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; - /** FlashList onEndReached callback. */ + /** Action list onEndReached callback. */ onEndReached: () => void; - /** FlashList onStartReached callback. */ + /** Action list onStartReached callback. */ onStartReached: () => void; - /** FlashList contentContainerStyle. */ + /** Action list contentContainerStyle. */ contentContainerStyle: StyleProp; /** Whether the initial report actions are still loading. */ diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index d74572515164..9d413ea42ab6 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,26 +1,26 @@ -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'; -import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRef, LegendListRenderItemProps, ViewToken as LegendListViewToken} from '@legendapp/list/react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; -import React, {memo, useEffect, useRef, useState} from 'react'; +import {LegendList} from '@legendapp/list/react-native'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {ExternalScrollFlashListTableHandle} from './ExternalScrollFlashListTable'; +import type {ExternalScrollLegendListTableHandle} from './ExternalScrollLegendListTable'; import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; -import ExternalScrollFlashListTable, {createScrollOffsetStore} from './ExternalScrollFlashListTable'; +import ExternalScrollLegendListTable, {createScrollOffsetStore} from './ExternalScrollLegendListTable'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; -/** Single virtualized data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ +/** Single virtualized data item rendered by the unified list. Mixes transactions, a footer marker, and report actions in one scroll. */ type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction}; const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; @@ -44,26 +44,6 @@ function unifiedListItemType(item: UnifiedListItem) { return item.type === 'report-action' ? item.action.actionName : item.type; } -type MoneyRequestReportFlashListProps = FlashListProps & { - /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ - ref: FlatListRefType; -}; - -/** - * Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based - * list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the - * ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is - * needed. The scroll manager relies on the FlashList registering into this slot. - */ -function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { - return ( - - // thin forwarder; spreading the props (including the ref) is the point - {...props} - /> - ); -} - type MoneyRequestReportUnifiedListProps = { /** Controller that owns the transaction rows and their selection/long-press state. */ controller: MoneyRequestReportTransactionListController; @@ -90,7 +70,7 @@ type MoneyRequestReportUnifiedListProps = { newTransactionID?: string; /** Ref to the underlying list, shared via the ActionList context. */ - listRef: FlatListRefType; + listRef: ActionListRefType; accessibilityLabel: string; @@ -152,8 +132,8 @@ function MoneyRequestReportUnifiedList({ listFooterComponent, }: MoneyRequestReportUnifiedListProps) { // When the table is wider than the viewport it can't share the horizontally-scrolled container with the chat (chat - // would drift sideways / jump on web). Instead the FlashList keeps ONLY the report actions virtualized, and the table is - // rendered as the list header via ExternalScrollFlashListTable — a nested FlashList in its own single native + // would drift sideways / jump on web). Instead the LegendList keeps ONLY the report actions virtualized, and the table is + // rendered as the list header via ExternalScrollLegendListTable, a nested LegendList in its own single native // horizontal scroller that windows its rows against THIS list's vertical scroll offset. Chat never lives inside a // horizontal scroller, so it never moves sideways. Everywhere else the transactions stay virtualized inline with // the report actions. @@ -180,10 +160,10 @@ function MoneyRequestReportUnifiedList({ const reportActionIndexOffset = shouldInlineTransactions ? controller.transactionListItems.length + 1 : 0; // Latest viewable items, kept current from onViewableItemsChanged, so the new-transaction scroll can skip when the row is already on screen. - const viewableItemsRef = useRef([]); + const viewableItemsRef = useRef>>([]); // Handle to the nested table (horizontal mode) — read for row page positions, never driven to scroll (its scroll is a no-op). - const tableRef = useRef(null); + const tableRef = useRef(null); // Viewport height + table offset fed to the nested table so it can window its rows against this list's scroll. // tableOffsetTop is the height of everything above the table region (the report-fields header). @@ -193,7 +173,7 @@ function MoneyRequestReportUnifiedList({ const [viewportHeight, setViewportHeight] = useState(windowHeight); const [tableOffsetTop, setTableOffsetTop] = useState(0); - // A subscribe/notify store carries the scroll offset to the nested table FlashList with zero parent re-renders. + // A subscribe/notify store carries the scroll offset to the nested table LegendList with zero parent re-renders. // Lazy useState initializer (not useRef.current) so it is created exactly once without reading a ref during render. const [scrollOffsetStore] = useState(createScrollOffsetStore); @@ -204,7 +184,7 @@ function MoneyRequestReportUnifiedList({ }, [report.reportID, scrollOffsetStore]); const handleScroll = (event: NativeSyntheticEvent) => { - // Always feed the offset store (emitter, not state: the nested FlashList updates its own render stack without + // Always feed the offset store (emitter, not state: the nested LegendList updates its own render window without // re-rendering the parent). Feed it even in inline mode — the store has no subscribers then, so this is a cheap // write — so that if the layout flips to the horizontal table, the nested list windows against the real scroll // offset instead of a stale 0. @@ -218,9 +198,9 @@ function MoneyRequestReportUnifiedList({ }; // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against - // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, + // raw LegendList indices. When transactions are present, report actions start at reportActionIndexOffset, // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. - const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + const onViewableItemsChangedAdjusted = (info: {viewableItems: Array>; changed: Array>}) => { // Keep the raw array so the new-transaction effect can tell whether the new row is already on screen. viewableItemsRef.current = info.viewableItems; if (reportActionIndexOffset === 0) { @@ -233,7 +213,12 @@ function MoneyRequestReportUnifiedList({ }); }; - const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { + const listExtraData = useMemo( + () => ({reportActionsExtraData, renderReportAction, renderTransactionListItem: controller.renderTransactionListItem, afterListContent: controller.afterListContent}), + [reportActionsExtraData, renderReportAction, controller.renderTransactionListItem, controller.afterListContent], + ); + + const dispatchRenderItem = ({item, index}: LegendListRenderItemProps) => { switch (item.type) { case 'section-header': case 'transaction': @@ -250,7 +235,7 @@ function MoneyRequestReportUnifiedList({ const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; - // FlashList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is + // LegendList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is // often not in `visibleReportActions` yet (it paginates in after mount), so the mount-only hint resolves to // undefined and the list never anchors on the linked message. Re-anchor imperatively once the linked action is // present. Guarded so it fires exactly once per linked target and never yanks the user after they've scrolled. @@ -302,7 +287,7 @@ function MoneyRequestReportUnifiedList({ return () => cancelAnimationFrame(rafId); } - // Horizontal table: the rows live in a nested FlashList whose own scroll is a no-op — only the parent page + // Horizontal table: the rows live in a nested LegendList whose own scroll is a no-op. Only the parent page // scrolls. Ask the table where the row sits in page space (works for unmounted rows) and scroll the parent there. const rafId = requestAnimationFrame(() => { scrolledToNewTransactionIDRef.current = newTransactionID; @@ -326,18 +311,30 @@ function MoneyRequestReportUnifiedList({ /> ); + const setListRef = useCallback( + (instance: LegendListRef | null) => { + const targetListRef = listRef; + if (!targetListRef) { + return; + } + targetListRef.current = instance; + }, + [listRef], + ); + return ( - + ref={setListRef} accessibilityLabel={accessibilityLabel} testID="money-request-report-actions-list" data={data} - extraData={reportActionsExtraData} + extraData={listExtraData} renderItem={dispatchRenderItem} keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} initialScrollIndex={initialScrollIndex} - maintainVisibleContentPosition={{autoscrollToBottomThreshold: undefined}} + maintainVisibleContentPosition + recycleItems onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={handleLayout} onEndReached={onEndReached} @@ -353,7 +350,7 @@ function MoneyRequestReportUnifiedList({ {reportFieldsHeader} {controller.beforeListContent} - + items={controller.transactionListItems} keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} diff --git a/src/components/PopoverMenu/v2/content/ScrollableContent.tsx b/src/components/PopoverMenu/v2/content/ScrollableContent.tsx index 95cc55b11527..09123411da89 100644 --- a/src/components/PopoverMenu/v2/content/ScrollableContent.tsx +++ b/src/components/PopoverMenu/v2/content/ScrollableContent.tsx @@ -36,7 +36,7 @@ function ScrollableContent({contentContainerStyle, children, ...rest}: Scrollabl const childCount = React.Children.count(children); if (childCount > VIRTUALIZATION_RECOMMENDED_THRESHOLD) { Log.warn( - ` received ${childCount} children — renders all rows synchronously and will jank on lower-end devices for unbounded counts. Consider a virtualized list (FlashList) wrapper.`, + ` received ${childCount} children — renders all rows synchronously and will jank on lower-end devices for unbounded counts. Consider a virtualized list (LegendList) wrapper.`, ); } } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx index 280a4d2433e1..5727f235fa65 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx @@ -25,7 +25,7 @@ import type {PersonalDetails, Policy, Report, ReportAction, Transaction, Transac import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; @@ -66,7 +66,7 @@ type MoneyRequestReportPreviewProviderProps = ChildrenProps & { lastTransactionViolations: TransactionViolations; onPaymentOptionsShow?: () => void; onPaymentOptionsHide?: () => void; - renderTransactionItem: ListRenderItem; + renderTransactionItem: NonNullable['renderItem']>; onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void; onCancelPendingPress?: () => void; currentWidth: number; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx index 630bf5135711..bee2b5ca99bc 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx @@ -5,7 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import {FlashList} from '@shopify/flash-list'; +import {LegendList} from '@legendapp/list/react-native'; import React from 'react'; import {View} from 'react-native'; @@ -49,7 +49,8 @@ function TransactionReportCarousel() { return ( - `${item.transactionID}_${reportPreviewStyles.transactionPreviewCarouselStyle.width}`} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx index ba5f633e4b57..ec640e3bfbc1 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx @@ -41,7 +41,7 @@ import ROUTES from '@src/ROUTES'; import {hasOnceLoadedReportActionsSelector, isLoadingInitialReportActionsSelector, pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; import type {ReportAction, ReportActions, Transaction} from '@src/types/onyx'; -import type {ListRenderItem} from '@shopify/flash-list'; +import type {LegendListProps} from '@legendapp/list/react-native'; import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -438,7 +438,7 @@ function MoneyRequestReportPreview({ [], ); - const renderItem: ListRenderItem = ({item}) => { + const renderItem: NonNullable['renderItem']> = ({item}) => { const transactionIOUAction = getIOUActionForReportID(item.reportID, item.transactionID); return ( void; - renderTransactionItem: ListRenderItem; + renderTransactionItem: NonNullable['renderItem']>; /** Called with the transactions in the order the carousel renders them */ onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx index ede408e2a1b1..30d54e30eb28 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {Policy, Report, Transaction} from '@src/types/onyx'; -import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {ViewToken} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -60,7 +60,7 @@ type UseReportPreviewCarouselParams = { newTransactionIDs?: Set; /** Renders a single transaction preview item */ - renderTransactionItem: ListRenderItem; + renderTransactionItem: NonNullable['renderItem']>; }; /** @@ -126,11 +126,11 @@ function useReportPreviewCarousel({ // value ensures that disabled state is applied instantly and not overridden by onViewableItemsChanged when scrolling // undefined makes arrow buttons react on currentIndex changes when scrolling manually const [optimisticIndex, setOptimisticIndex] = useState(undefined); - const carouselRef = useRef | null>(null); + const carouselRef = useRef(null); // Expose a callback ref instead of the ref object so the ref does not flow through the hook's return value // (React Compiler forbids reading/passing refs during render). - const setCarouselRef = useCallback((node: FlashListRef | null) => { + const setCarouselRef = useCallback((node: LegendListRef | null) => { carouselRef.current = node; }, []); const prevTransactionCountForScroll = useRef(carouselTransactions.length); @@ -217,7 +217,7 @@ function useReportPreviewCarousel({ } if (index < 0) { setOptimisticIndex(0); - carouselRef.current?.scrollToTop({animated: true}); + carouselRef.current?.scrollToOffset({offset: 0, animated: true}); return; } if (index === carouselTransactions.length - visibleItemsOnEndCount) { @@ -232,7 +232,7 @@ function useReportPreviewCarousel({ }); }; - const renderItem = (itemInfo: ListRenderItemInfo) => { + const renderItem: NonNullable['renderItem']> = (itemInfo) => { if (itemInfo.index > MAX_PREVIEWS_NUMBER - 1) { return ( 1 && index < shownImages.length - 1; const borderStyle = shouldShowBorder ? styles.reportActionItemImageBorder : {}; - const key = getMappingKey(image ?? '', index); + const key = `${image ?? ''}:${index}`; return ( { - if (!isGroupHeaderItem(item)) { - return; - } - // FlashList requires mutating the layout object passed to overrideItemLayout. - // eslint-disable-next-line no-param-reassign -- FlashList overrideItemLayout API - layout.size = variables.tableRowHeight; - }; - - const stickyHeaderConfig = shouldSplit ? {hideRelatedCell: true, useNativeDriver: true, zIndex: 2} : undefined; + const getFixedItemSize = (item: SearchListItem) => (isGroupHeaderItem(item) ? variables.tableRowHeight : undefined); const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { if (isGroupHeaderItem(item)) { @@ -348,9 +339,8 @@ function ExpenseGroupedSearchView({ nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} stickyHeaderIndices={stickyHeaderIndices} getItemType={getItemType} - stickyHeaderConfig={stickyHeaderConfig} disabledIndexes={shouldSplit ? childrenContainerIndices : undefined} - overrideItemLayout={shouldSplit ? overrideItemLayout : undefined} + getFixedItemSize={shouldSplit ? getFixedItemSize : undefined} /> {modal} diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 8164250b667d..3e9a4fa44875 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -1,13 +1,10 @@ import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; -import {FlashList} from '@shopify/flash-list'; +import {AnimatedLegendList} from '@legendapp/list/reanimated'; import React, {useCallback} from 'react'; -import Animated from 'react-native-reanimated'; import type BaseSearchListProps from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); - function BaseSearchList({ data, renderItem, @@ -22,6 +19,7 @@ function BaseSearchList({ contentContainerStyle, stickyHeaderIndices, getItemType, + getFixedItemSize, }: BaseSearchListProps) { const renderItemWithoutKeyboardFocus = useCallback( ({item, index}: {item: SearchListItem; index: number}) => { @@ -31,24 +29,25 @@ function BaseSearchList({ ); return ( - ); } diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 5f7273b9f6f8..02f7b74e4279 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -16,38 +16,15 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isModalActiveSelector} from '@src/selectors/Modal'; -import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, NativeSyntheticEvent} from 'react-native'; +import {AnimatedLegendList} from '@legendapp/list/reanimated'; import {useIsFocused} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; -import Animated from 'react-native-reanimated'; import type BaseSearchListProps from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); - -type CellRendererComponentProps = ViewProps & { - ref?: React.Ref; - style?: StyleProp; -}; - -function CellRendererComponent({children, ref, style, ...props}: CellRendererComponentProps) { - const styles = useThemeStyles(); - - return ( - - {children} - - ); -} - function BaseSearchList({ data, columns, @@ -71,8 +48,9 @@ function BaseSearchList({ stickyHeaderConfig, getItemType, disabledIndexes, - overrideItemLayout, + getFixedItemSize, }: BaseSearchListProps) { + const styles = useThemeStyles(); const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); const {focusedCellId, isEditingCell} = useEditingCellState(); @@ -95,9 +73,6 @@ function BaseSearchList({ onFocusedIndexChange: (index: number) => { scrollToIndex?.(index); }, - onArrowUpDownCallback: () => { - ref?.current?.announceProgrammaticScroll(); - }, setHasKeyBeenPressed, isFocused, captureOnInputs: false, @@ -126,7 +101,7 @@ function BaseSearchList({ const renderItemWithKeyboardFocus = ({item, index}: {item: SearchListItem; index: number}) => { const isItemFocused = focusedIndex === index; - return renderItem(item, index, isItemFocused, getOnFocus(index)); + return {renderItem(item, index, isItemFocused, getOnFocus(index))}; }; const selectFocusedOption = useCallback( @@ -171,12 +146,12 @@ function BaseSearchList({ }, [setHasKeyBeenPressed]); const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + () => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy, renderItem], + [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy, renderItem], ); return ( - ); } diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index c18b7a1719df..dfaafd97018c 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -4,12 +4,12 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/typ import type {CardList, Transaction} from '@src/types/onyx'; -import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; type BaseSearchListProps = Pick< - FlashListProps, + LegendListProps, | 'onScroll' | 'contentContainerStyle' | 'onEndReached' @@ -21,7 +21,7 @@ type BaseSearchListProps = Pick< | 'onLayout' | 'stickyHeaderIndices' | 'stickyHeaderConfig' - | 'overrideItemLayout' + | 'getFixedItemSize' > & { data: SearchListItem[]; renderItem: (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element; @@ -37,7 +37,7 @@ type BaseSearchListProps = Pick< /** The callback, which is run when a row is pressed */ onSelectRow: (item: SearchListItem) => void; - ref: RefObject | null>; + ref: RefObject; scrollToIndex?: (index: number, animated?: boolean) => void; /** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */ @@ -46,8 +46,8 @@ type BaseSearchListProps = Pick< /** Non-personal and workspace cards for triggering re-render via extraData */ nonPersonalAndWorkspaceCards?: CardList; - /** Function to determine item type for FlashList recycling */ - getItemType?: (item: SearchListItem, index: number) => string | number | undefined; + /** Function to determine item type for LegendList recycling */ + getItemType?: LegendListProps['getItemType']; /** Indexes to skip during keyboard arrow navigation */ disabledIndexes?: readonly number[]; diff --git a/src/components/Search/hooks/useSearchListViewState.ts b/src/components/Search/hooks/useSearchListViewState.ts index c7f94336779b..076b7f140592 100644 --- a/src/components/Search/hooks/useSearchListViewState.ts +++ b/src/components/Search/hooks/useSearchListViewState.ts @@ -21,7 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import {useRef} from 'react'; @@ -65,7 +65,7 @@ function useSearchListViewState({data, listData = data, isMobileSelectionModeEna const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); const {isEditingCell, wasRecentlyEditingCell} = useEditingCellState(); - const listRef = useRef>(null); + const listRef = useRef(null); const prevDataLength = usePrevious(data.length); const hasItemsBeingRemoved = !!prevDataLength && prevDataLength > data.length; diff --git a/src/components/Search/primitives/useScrollRestoration.ts b/src/components/Search/primitives/useScrollRestoration.ts index bde273561304..749e0664575e 100644 --- a/src/components/Search/primitives/useScrollRestoration.ts +++ b/src/components/Search/primitives/useScrollRestoration.ts @@ -1,6 +1,6 @@ import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; import {useFocusEffect, useRoute} from '@react-navigation/native'; @@ -10,10 +10,10 @@ import {useCallback, useContext} from 'react'; * Restores the Search list's vertical scroll position when the screen regains focus. * * The offset is saved per route in ScrollOffsetContext by the page wrappers; on focus we read it back - * and apply it to the FlashList on the next frame, so a back-navigation lands at the prior position + * and apply it to the LegendList on the next frame, so a back-navigation lands at the prior position * instead of the top. Extracted from SearchList so ExpenseFlatSearchView can reuse it. */ -function useScrollRestoration(listRef: RefObject | null>) { +function useScrollRestoration(listRef: RefObject) { const route = useRoute(); const {getScrollOffset} = useContext(ScrollOffsetContext); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index daaf6f8bc419..7834ec1dc31c 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -7,10 +7,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import getEmptyArray from '@src/types/utils/getEmptyArray'; -import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListProps, LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; +import {LegendList} from '@legendapp/list/react-native'; import {useIsFocused} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; @@ -112,7 +112,7 @@ function BaseSelectionListImpl({ // Kept out of the destructuring default so the `!!` doesn't bail the component out of React Compiler. const shouldShowTextInput = shouldShowTextInputProp ?? !!textInputOptions?.label; - const listRef = useRef | null>(null); + const listRef = useRef(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, data); const itemFocusTimeoutRef = useRef(null); const keyboardListenerRef = useRef | null>(null); @@ -178,16 +178,11 @@ function BaseSelectionListImpl({ shouldDebounceScrolling, scrollToIndex, debouncedScrollToIndex, - announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(), setShouldDisableHoverStyle, }); const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed); - // extraData helps FlashList detect when data changes significantly (e.g., during filtering) - // Including data.length ensures FlashList resets its layout cache when the list size changes - // This prevents "index out of bounds" errors when filtering reduces the list size - const extraData = useMemo(() => [data.length], [data.length]); const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; const selectRow = useCallback( @@ -296,7 +291,7 @@ function BaseSelectionListImpl({ ); }; - const renderItem: ListRenderItem = ({item, index}: ListRenderItemInfo) => { + const renderItem: NonNullable['renderItem']> = ({item, index}: LegendListRenderItemProps) => { const selected = isItemSelected(item); const isItemDisabled = isDisabled || (!!item.isDisabled && !selected); const isItemFocused = (!isDisabled || selected) && focusedIndex === index; @@ -516,13 +511,13 @@ function BaseSelectionListImpl({ ) : ( <> {!shouldHeaderBeInsideList && header} - item.keyForList} - extraData={extraData} + extraData={renderItem} ListFooterComponent={listFooterContent} ListFooterComponentStyle={style?.listFooterContentStyle} scrollEnabled={scrollEnabled} @@ -536,7 +531,7 @@ function BaseSelectionListImpl({ contentContainerStyle={[styles.pb3, style?.contentContainerStyle]} initialScrollIndex={shouldScrollToFocusedIndexOnMount ? initialFocusedIndex : undefined} onScrollBeginDrag={onScrollBeginDrag} - maintainVisibleContentPosition={{disabled: disableMaintainingScrollPosition}} + maintainVisibleContentPosition={!disableMaintainingScrollPosition} ListHeaderComponent={ <> {customListHeaderContent} diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index c341c3cafb9a..23729ae6d1fe 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -22,11 +22,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {ValueOf} from 'type-fest'; +import {LegendList} from '@legendapp/list/react-native'; import {useIsFocused} from '@react-navigation/native'; -import {FlashList} from '@shopify/flash-list'; import React, {useCallback, useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; @@ -36,12 +36,6 @@ function getItemType(item: FlattenedItem): ValueOf> | null>(null); + const listRef = useRef(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, flattenedData); const {containerRef, trackScrollOffset, scrollInputIntoView} = useScrollToFocusedInput(listRef, isKeyboardShown); @@ -122,7 +114,6 @@ function BaseSelectionListWithSectionsImpl({ shouldDebounceScrolling, scrollToIndex, debouncedScrollToIndex, - announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(), setShouldDisableHoverStyle, }); @@ -278,7 +269,7 @@ function BaseSelectionListWithSectionsImpl({ ); }; - const renderItem = ({item, index}: ListRenderItemInfo>) => { + const renderItem = ({item, index}: LegendListRenderItemProps>) => { if (!item) { return null; } @@ -350,15 +341,14 @@ function BaseSelectionListWithSectionsImpl({ listEmptyContent={listEmptyContent} /> ) : ( - ('flatListKey' in item ? item.flatListKey : item.keyForList)} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} @@ -377,7 +367,7 @@ function BaseSelectionListWithSectionsImpl({ ListFooterComponentStyle={style?.listFooterContentStyle} style={style?.listStyle} contentContainerStyle={style?.contentContainerStyle} - maintainVisibleContentPosition={{disabled: true}} + maintainVisibleContentPosition={false} /> )} {!!footerContent && ( diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts index 1d7010e067e3..3cdc62325671 100644 --- a/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts @@ -1,6 +1,6 @@ import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; @@ -15,7 +15,7 @@ type UseScrollToFocusedInputResult = { scrollInputIntoView: (input: MeasurableInput) => void; }; -type UseScrollToFocusedInput = (listRef: RefObject, 'scrollToOffset'> | null>, isKeyboardShown: boolean) => UseScrollToFocusedInputResult; +type UseScrollToFocusedInput = (listRef: RefObject | null>, isKeyboardShown: boolean) => UseScrollToFocusedInputResult; // eslint-disable-next-line import/prefer-default-export export type {UseScrollToFocusedInput}; diff --git a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts index 176ed6162cba..951411053fa1 100644 --- a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts +++ b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts @@ -18,7 +18,6 @@ type UseSelectionListKeyboardFocusParams = { shouldDebounceScrolling: boolean; scrollToIndex: ScrollToIndex; debouncedScrollToIndex: ScrollToIndex; - announceProgrammaticScroll: () => void; setShouldDisableHoverStyle: (shouldDisableHoverStyle: boolean) => void; }; @@ -40,7 +39,6 @@ function useSelectionListKeyboardFocus({ shouldDebounceScrolling, scrollToIndex, debouncedScrollToIndex, - announceProgrammaticScroll, setShouldDisableHoverStyle, }: UseSelectionListKeyboardFocusParams): UseSelectionListKeyboardFocusResult { const hasKeyBeenPressed = useRef(false); @@ -82,7 +80,6 @@ function useSelectionListKeyboardFocus({ isFocused, onArrowUpDownCallback: () => { setShouldDisableHoverStyle(true); - announceProgrammaticScroll(); }, }); diff --git a/src/components/SelectionList/hooks/useSelectionListScroll.ts b/src/components/SelectionList/hooks/useSelectionListScroll.ts index 5165585e73c5..05211a37a996 100644 --- a/src/components/SelectionList/hooks/useSelectionListScroll.ts +++ b/src/components/SelectionList/hooks/useSelectionListScroll.ts @@ -4,7 +4,7 @@ import Log from '@libs/Log'; import CONST from '@src/CONST'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; type ScrollToIndex = (index: number, animated?: boolean) => void; @@ -14,8 +14,8 @@ type UseSelectionListScrollResult = { debouncedScrollToIndex: ScrollToIndex; }; -/** Bounds-checked scroll-to-index helpers (immediate + debounced) over the component-owned FlashList ref. */ -function useSelectionListScroll(listRef: RefObject, 'scrollToIndex'> | null>, data: TData[]): UseSelectionListScrollResult { +/** Bounds-checked scroll-to-index helpers (immediate + debounced) over the component-owned LegendList ref. */ +function useSelectionListScroll(listRef: RefObject | null>, data: TData[]): UseSelectionListScrollResult { const scrollToIndex: ScrollToIndex = (index, animated = true) => { if (index < 0 || index >= data.length || !listRef.current) { return; @@ -25,9 +25,11 @@ function useSelectionListScroll(listRef: RefObject { + Log.warn('SelectionList: error scrolling to index', {error}); + }); } catch (error) { - // FlashList can throw if this index isn't laid out yet (e.g. rapid search filtering); it resolves on the next render. + // LegendList can throw if this index isn't laid out yet (e.g. rapid search filtering); it resolves on the next render. Log.warn('SelectionList: error scrolling to index', {error}); } }; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 948228b4f87f..09acd5c5b161 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -16,7 +16,7 @@ import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager'; import CONST from '@src/CONST'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {ReactElement} from 'react'; import type {LayoutChangeEvent} from 'react-native'; @@ -28,7 +28,7 @@ import type {TableContextValue} from './TableContext'; import type {TableHeaderProps} from './TableHeader'; import type {TableData, TableHandle, TableMethods, TableProps, TableRow} from './types'; -import {getDataVisibleIndices, getListIndex, getTableListMetadata} from './buildTableListData'; +import {getDataIndex, getDataVisibleIndices, getListIndex, getTableListMetadata} from './buildTableListData'; import useFiltering from './middlewares/filtering'; import useHighlighting from './middlewares/highlight'; import useSearching from './middlewares/searching'; @@ -64,17 +64,16 @@ function isTableListHeaderElement(child: React.ReactNode): child is ReactElement /** * Builds the Proxy exposed through the Table's ref, forwarding to `tableMethods` first and - * falling back to FlashList's own methods (e.g. `scrollToIndex`). + * falling back to LegendList's own methods (e.g. `scrollToIndex`). * * This is a standalone top-level function (rather than being inlined in the `useImperativeHandle` * callback) because OXC's React Compiler currently fails to compile a component when a generic type * cast referencing the component's own type parameters (e.g. `as TableHandle`) - * appears inside a nested closure. That bailout is silent (no build warning) and disables automatic - * memoization for the entire file, which is what previously caused an infinite FlashList re-render. + * appears inside a nested closure. That bailout is silent and disables automatic memoization for the entire file. */ function createTableHandle( tableMethods: TableMethods, - listRef: React.RefObject | null>, + listRef: React.RefObject, getProcessedData: () => Array>, tableListMetadata: TableListMetadata, ): TableHandle { @@ -94,49 +93,64 @@ function createTableHandle['scrollToIndex']>[0]) => + return (params: Parameters[0]) => scrollToIndex({ ...params, index: getListIndex(params.index, tableListMetadata), }); } - if (property === 'getLayout') { - const getLayout = listRef.current?.getLayout; - if (tableListMetadata.listDataRowOffset === 0 || !getLayout) { - return getLayout; + if (property === 'scrollIndexIntoView') { + const scrollIndexIntoView = listRef.current?.scrollIndexIntoView; + if (tableListMetadata.listDataRowOffset === 0 || !scrollIndexIntoView) { + return scrollIndexIntoView; } - return (index: number) => getLayout(getListIndex(index, tableListMetadata)); - } - - if (property === 'computeVisibleIndices') { - const computeVisibleIndices = listRef.current?.computeVisibleIndices; - if (tableListMetadata.listDataRowOffset === 0 || !computeVisibleIndices) { - return computeVisibleIndices; - } - - return () => getDataVisibleIndices(computeVisibleIndices(), tableListMetadata); + return (params: Parameters[0]) => + scrollIndexIntoView({ + ...params, + index: getListIndex(params.index, tableListMetadata), + }); } - if (property === 'getFirstVisibleIndex') { - const computeVisibleIndices = listRef.current?.computeVisibleIndices; - const getFirstVisibleIndex = listRef.current?.getFirstVisibleIndex; - if (tableListMetadata.listDataRowOffset === 0 || !computeVisibleIndices) { - return getFirstVisibleIndex; + if (property === 'getState') { + const getState = listRef.current?.getState; + if (tableListMetadata.listDataRowOffset === 0 || !getState) { + return getState; } - return () => { - const {startIndex} = getDataVisibleIndices(computeVisibleIndices(), tableListMetadata); - return startIndex; - }; + return () => getTableListState(getState(), tableListMetadata); } - return listRef.current?.[property as keyof FlashListRef]; + return listRef.current?.[property as keyof LegendListRef]; }, }) as TableHandle; } +function getTableListState(state: ReturnType, tableListMetadata: TableListMetadata): ReturnType { + const {startIndex, endIndex} = getDataVisibleIndices({startIndex: state.start, endIndex: state.end}, tableListMetadata); + const {startIndex: startBuffered, endIndex: endBuffered} = getDataVisibleIndices({startIndex: state.startBuffered, endIndex: state.endBuffered}, tableListMetadata); + + return { + ...state, + data: state.data.slice(tableListMetadata.listDataRowOffset), + start: startIndex, + end: endIndex, + startBuffered, + endBuffered, + elementAtIndex: (index) => { + const element: unknown = state.elementAtIndex(getListIndex(index, tableListMetadata)); + return element; + }, + indexByKey: (key) => { + const index = state.indexByKey(key); + return index === undefined ? undefined : getDataIndex(index, tableListMetadata); + }, + positionAtIndex: (index) => state.positionAtIndex(getListIndex(index, tableListMetadata)), + sizeAtIndex: (index) => state.sizeAtIndex(getListIndex(index, tableListMetadata)), + }; +} + /** * A composable table component that provides filtering, search, and sorting functionality. * @@ -151,7 +165,7 @@ function createTableHandle` - The parent component that manages state and provides context * - `` - Renders sortable column headers - * - `` - Renders the data rows using FlashList + * - `` - Renders the data rows using LegendList * - `` - Renders a search input that filters data * * ## Middleware Architecture @@ -328,7 +342,7 @@ function Table(); const processedData = highlightMiddleware(selectionData); - const listRef = useRef>(null); + const listRef = useRef(null); const releaseBackgroundInputFocusSuppressionRef = useRef<(() => void) | null>(null); const mobileSelectionModalRowKeyRef = useRef(mobileSelectionModalRowKey); const [shouldSubmitMobileSelection, setShouldSubmitMobileSelection] = useState(false); @@ -410,7 +424,7 @@ function Table createTableHandle(tableMethods, listRef, () => processedData, tableListMetadata)); diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 3063c70a6920..ff6b8cc9b0b4 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -7,17 +7,17 @@ import useLocalize from '@hooks/useLocalize'; import useScrollEnabled from '@hooks/useScrollEnabled'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ListRenderItemInfo, ViewToken} from '@shopify/flash-list'; +import type {LegendListRenderItemProps, ViewToken} from '@legendapp/list/react-native'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; -import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {LegendList} from '@legendapp/list/react-native'; +import React, {useCallback, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; import type {TableData} from '.'; import type {TableListMetadata} from './buildTableListData'; -import {buildTableListData, getAdjustedStickyHeaderIndices, getDataIndex, getListIndex, getSyntheticRowKind} from './buildTableListData'; +import {buildTableListData, getAdjustedStickyHeaderIndices, getDataIndex, getDataVisibleIndices, getListIndex, getSyntheticRowKind} from './buildTableListData'; import {getRowGroupAccessibilityProps, getTableContainerAccessibilityProps, getVirtualizedRowSemanticID, shouldUseTableSemantics} from './tableAccessibility'; import {TableRowSemanticIDContext, useTableContext} from './TableContext'; @@ -25,7 +25,7 @@ import {TableRowSemanticIDContext, useTableContext} from './TableContext'; * Props for the TableBody component. */ type TableBodyProps = ViewProps & { - /** Optional custom styles for the FlashList content container. */ + /** Optional custom styles for the LegendList content container. */ contentContainerStyle?: StyleProp; }; @@ -37,6 +37,10 @@ type TableBodyListProps = TableBodyProps & { type ViewabilityInfo = { viewableItems: Array>; changed: Array>; + start: number; + end: number; + startBuffered: number; + endBuffered: number; }; function getDataViewabilityInfo(info: ViewabilityInfo, metadata: TableListMetadata): ViewabilityInfo { @@ -52,9 +56,17 @@ function getDataViewabilityInfo(info: ViewabilityInfo, metadata: TableListMetada return {...token, index: getDataIndex(token.index, metadata)}; }; + const visibleIndices = getDataVisibleIndices({startIndex: info.start, endIndex: info.end}, metadata); + const bufferedIndices = getDataVisibleIndices({startIndex: info.startBuffered, endIndex: info.endBuffered}, metadata); + return { + ...info, viewableItems: info.viewableItems.map(getDataViewToken).filter((token): token is ViewToken => token !== null), changed: info.changed.map(getDataViewToken).filter((token): token is ViewToken => token !== null), + start: visibleIndices.startIndex, + end: visibleIndices.endIndex, + startBuffered: bufferedIndices.startIndex, + endBuffered: bufferedIndices.endIndex, }; } @@ -68,9 +80,9 @@ function doesBodyRenderWhenEmpty(listProps: {ListEmptyComponent?: unknown; ListH } /** - * Renders the table body using FlashList when data rows are present or a page-header search/filter has no results. + * Renders the table body using LegendList when data rows are present or a page-header search/filter has no results. * - * This component consumes the Table context to access processed data and FlashList props. + * This component consumes the Table context to access processed data and LegendList props. * It automatically handles empty states, including a special "no results found" message * when search returns no results but original data exists. * @@ -98,9 +110,6 @@ function doesBodyRenderWhenEmpty(listProps: {ListEmptyComponent?: unknown; ListH function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, ...props}: TableBodyListProps) { const styles = useThemeStyles(); const scrollEnabled = useScrollEnabled(); - const [isListLoaded, setIsListLoaded] = useState(false); - const [hasActivatedStickyHeader, setHasActivatedStickyHeader] = useState(false); - const [activeStickyHeaderIndex, setActiveStickyHeaderIndex] = useState(-1); const { processedData: filteredAndSortedData, listProps, @@ -120,7 +129,6 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. } = useTableContext(); const { ListEmptyComponent, - ListEmptyComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -130,7 +138,6 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. keyExtractor, onEndReached, onLoad, - onChangeStickyIndex, onScroll, onStartReached, onViewableItemsChanged, @@ -140,6 +147,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. viewabilityConfigCallbackPairs, ...restListProps } = listProps ?? {}; + const extraData: unknown = listProps?.extraData; const tableBodyContentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding: true, @@ -153,52 +161,20 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. const contentMinHeight = flattenedContentContainerStyle?.minHeight; const {paddingBottom: tableBodyBottomPadding} = StyleSheet.flatten(tableBodyContentContainerStyle) ?? {}; - const shouldRenderStickyHeader = tableListMetadata.shouldRenderStickyHeader; const hasRows = filteredAndSortedData.length > 0; - const shouldRenderFlashList = hasRows || (tableListMetadata.hasPageHeader && isEmptyResult); + const shouldRenderLegendList = hasRows || (tableListMetadata.hasPageHeader && isEmptyResult); const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); const shouldApplyPageHeaderTable = isTableSemanticsEnabled && tableListMetadata.hasPageHeader && hasRows; const shouldApplyBodyRowGroup = isTableSemanticsEnabled && !tableListMetadata.hasPageHeader; const semanticTableHasHeader = !tableListMetadata.hasPageHeader || tableListMetadata.shouldRenderStickyHeader; const semanticColumnCount = columns.length + (selectionEnabled ? 1 : 0); + const rowExtraData = useMemo( + () => ({extraData, renderItem, tableHeaderElement, tableListMetadata, isTableSemanticsEnabled}), + [extraData, renderItem, tableHeaderElement, tableListMetadata, isTableSemanticsEnabled], + ); const tableBodyAccessibilityProps = tableListMetadata.hasPageHeader ? getTableContainerAccessibilityProps(shouldApplyPageHeaderTable, title, filteredAndSortedData.length, semanticColumnCount, semanticTableHasHeader) : getRowGroupAccessibilityProps(shouldApplyBodyRowGroup); - const currentListState = {shouldRenderFlashList, shouldRenderStickyHeader}; - const [previousListState, setPreviousListState] = useState(currentListState); - const shouldResetListLoad = previousListState.shouldRenderFlashList !== shouldRenderFlashList; - const shouldResetStickyHeader = previousListState.shouldRenderStickyHeader !== shouldRenderStickyHeader; - - if (shouldResetListLoad || shouldResetStickyHeader) { - setPreviousListState(currentListState); - - if (shouldResetListLoad) { - setIsListLoaded(false); - } - - if (shouldResetStickyHeader) { - setHasActivatedStickyHeader(false); - setActiveStickyHeaderIndex(-1); - } - } - - useEffect(() => { - if (!hasRows || !tableListMetadata.shouldRenderStickyHeader || !isListLoaded || hasActivatedStickyHeader) { - return; - } - - const frame = requestAnimationFrame(() => setHasActivatedStickyHeader(true)); - return () => cancelAnimationFrame(frame); - }, [hasActivatedStickyHeader, hasRows, isListLoaded, tableListMetadata.shouldRenderStickyHeader]); - - const handleChangeStickyIndex: NonNullable = useCallback( - (current, previous) => { - setActiveStickyHeaderIndex((activeIndex) => (activeIndex === current ? activeIndex : current)); - onChangeStickyIndex?.(current, previous); - }, - [onChangeStickyIndex], - ); - const handleViewableItemsChanged: NonNullable = useCallback( (info) => onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)), [onViewableItemsChanged, tableListMetadata], @@ -214,17 +190,22 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ); const overrideItemLayoutForList: NonNullable = useCallback( - (layout, item, index, maxColumns, extraData) => { + (layout, item, index, maxColumns) => { if (getSyntheticRowKind(index, tableListMetadata) !== 'data') { return; } overrideItemLayout?.(layout, item, getDataIndex(index, tableListMetadata), maxColumns, extraData); }, - [overrideItemLayout, tableListMetadata], + [extraData, overrideItemLayout, tableListMetadata], ); - const initialScrollIndexForList = initialScrollIndex == null ? initialScrollIndex : getListIndex(initialScrollIndex, tableListMetadata); + let initialScrollIndexForList = initialScrollIndex; + if (typeof initialScrollIndex === 'number') { + initialScrollIndexForList = getListIndex(initialScrollIndex, tableListMetadata); + } else if (initialScrollIndex) { + initialScrollIndexForList = {...initialScrollIndex, index: getListIndex(initialScrollIndex.index, tableListMetadata)}; + } const renderListComponent = (component: typeof ListHeaderComponent | typeof ListEmptyComponent | typeof ListFooterComponent) => { if (!component) { @@ -275,7 +256,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. }, ]; - if (!shouldRenderFlashList) { + if (!shouldRenderLegendList) { return ( no results -> rows transitions. - // FlashList renders ListHeaderComponent outside its virtualized item collection, so controls such + // Keep the page header in the same LegendList across rows -> no results -> rows transitions. + // LegendList renders ListHeaderComponent outside its virtualized item collection, so controls such // as the search input keep their identity. The full-layout wrapper below is the semantic table ancestor; // keeping rows in their physical accessibility tree avoids focus/scroll jumps caused by detached aria-owns rows. // A truly empty table still uses the standalone centered layout above. const listData = buildTableListData(filteredAndSortedData, tableListMetadata); const adjustedStickyHeaderIndices = getAdjustedStickyHeaderIndices(tableListMetadata, stickyHeaderIndices); - const canRenderStickyHeader = !tableListMetadata.shouldRenderStickyHeader || (isListLoaded && hasActivatedStickyHeader); - const isTableHeaderSticky = activeStickyHeaderIndex === tableListMetadata.stickyTableHeaderIndex; const shouldRenderEmptyStateInList = !hasRows && tableListMetadata.hasPageHeader; const handleLoad: NonNullable = (info) => { - setIsListLoaded(true); onLoad?.(info); }; - const renderListItem = (info: ListRenderItemInfo) => { + const renderListItem = (info: LegendListRenderItemProps) => { const rowKind = getSyntheticRowKind(info.index, tableListMetadata); switch (rowKind) { @@ -328,23 +306,19 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. return null; } - const isAccessibleTableHeader = info.target === (isTableHeaderSticky ? 'StickyHeader' : 'Cell'); - const isAccessibilityHidden = isTableSemanticsEnabled && !isAccessibleTableHeader; return React.cloneElement(tableHeaderElement, { isStickyListHeader: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'aria-hidden': isAccessibilityHidden ? true : undefined, - isAccessibilityHidden, }); } case 'data': default: { const dataIndex = getDataIndex(info.index, tableListMetadata); - const semanticRowID = getVirtualizedRowSemanticID(isTableSemanticsEnabled, info.target); + const semanticRowID = getVirtualizedRowSemanticID(isTableSemanticsEnabled); return ( {renderItem?.({ ...info, + extraData, index: dataIndex, }) ?? null} @@ -363,14 +337,14 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. return keyExtractor?.(item, getDataIndex(index, tableListMetadata)) ?? item.keyForList; }; - const getItemTypeForList = (item: TableData, index: number, extraData: unknown) => { + const getItemTypeForList = (item: TableData, index: number) => { const rowKind = getSyntheticRowKind(index, tableListMetadata); if (rowKind !== 'data') { return item.keyForList; } - return getItemType?.(item, getDataIndex(index, tableListMetadata), extraData); + return getItemType?.(item, getDataIndex(index, tableListMetadata)); }; return ( @@ -381,20 +355,18 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. {...tableBodyAccessibilityProps} {...props} > - + ref={listRef} data={listData} style={[styles.flex1, styles.mnh0]} showsVerticalScrollIndicator={false} - maintainVisibleContentPosition={{disabled: true}} + maintainVisibleContentPosition={false} ListHeaderComponent={pageHeaderElement} - ListEmptyComponent={shouldRenderEmptyStateInList ? emptyStateContent : ListEmptyComponent} - ListEmptyComponentStyle={[ListEmptyComponentStyle, shouldRenderEmptyStateInList && styles.flexGrow1, shouldRenderEmptyStateInList && styles.justifyContentCenter]} + ListEmptyComponent={shouldRenderEmptyStateInList ? {emptyStateContent} : ListEmptyComponent} ListFooterComponent={ListFooterComponent} ListFooterComponentStyle={shouldRenderEmptyStateInList ? emptyStateFooterStyle : ListFooterComponentStyle} onLoad={handleLoad} - onChangeStickyIndex={handleChangeStickyIndex} - stickyHeaderIndices={hasRows && canRenderStickyHeader ? adjustedStickyHeaderIndices : undefined} + stickyHeaderIndices={hasRows ? adjustedStickyHeaderIndices : undefined} contentContainerStyle={[ listContentContainerStyle, tableBodyContentContainerStyle, @@ -421,6 +393,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. onScroll?.(event); }} {...restListProps} + extraData={rowExtraData} scrollEnabled={scrollEnabled} /> diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index e21873f50acf..109aa11d7ff2 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -1,6 +1,6 @@ import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; -import type {FlashListRef} from '@shopify/flash-list'; +import type {LegendListRef} from '@legendapp/list/react-native'; import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; import React, {createContext, useContext} from 'react'; @@ -22,7 +22,7 @@ type TableContextValue | null>; + /** Reference to the underlying LegendList for programmatic control. */ + listRef: React.RefObject; /** Ref for the view wrapping the table list; its top is the anchor used when scrolling a focused input above the keyboard. */ listContainerRef: React.RefObject; @@ -46,7 +46,7 @@ type TableContextValue void; - /** FlashList props passed through from the Table component. */ + /** LegendList props passed through from the Table component. */ listProps: SharedListProps; /** Whether or not selection is enabled for the table */ @@ -95,7 +95,7 @@ type TableContextValue