diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts index cf7718d3d148..fb3a8b6619db 100644 --- a/src/components/FlashList/types.ts +++ b/src/components/FlashList/types.ts @@ -1,7 +1,7 @@ +import type {FlashListRef} from '@shopify/flash-list'; import type {RefObject} from 'react'; -import type {FlatList} from 'react-native'; /** Ref to the underlying list instance attached via `ref={}`. */ -type FlatListRefType = RefObject | null> | null; +type FlatListRefType = RefObject | null> | null; export default FlatListRefType; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index cfb299fa9dbd..4e397017ed65 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -48,13 +48,12 @@ function unifiedListItemType(item: UnifiedListItem) { } type MoneyRequestReportFlashListProps = FlashListProps & { - /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ + /** Ref to the underlying list, shared via the ActionList context. */ 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 + * Forwards the shared ActionList context ref to the underlying FlashList. 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. */ diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 76fff4bcf9b8..9e388380c009 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -161,7 +161,7 @@ function useReportActionsScroll({ setTreatAsNoPaginationAnchor, }: UseReportActionsScrollParams): UseReportActionsScrollResult { const reportScrollManager = useReportScrollManager(); - const {scrollOffsetRef} = useActionListContext(); + const {scrollOffsetRef, getListRef} = useActionListContext(); const {windowHeight} = useWindowDimensions(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; @@ -243,6 +243,16 @@ function useReportActionsScroll({ scrollOffsetRef.current = event.nativeEvent.contentOffset.y; }, actionBadgeTargetIndex, + getVisibleIndexRange: () => { + const list = getListRef()?.current; + // getLayout is the one accessor that does not throw before FlashList has laid out + if (!list?.getLayout(0)) { + return undefined; + } + const range = list.computeVisibleIndices(); + // An empty viewport comes back as {-1, -2}, which would read as "target above", so fall back to the reported items + return range.startIndex < 0 ? undefined : range; + }, shouldBeAlignedToTop, }); diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 756ae8fc3b76..0852d17430cb 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,9 +1,5 @@ import {useActionListContext} from '@pages/inbox/ActionListContext'; -import type {ComponentRef} from 'react'; -// eslint-disable-next-line no-restricted-imports -import type {ScrollView} from 'react-native'; - import type {ReportScrollManagerData, ScrollToIndexOptions} from './types'; function useReportScrollManager(): ReportScrollManagerData { @@ -44,7 +40,7 @@ function useReportScrollManager(): ReportScrollManagerData { return; } - const scrollViewRef = listRef.current.getNativeScrollRef?.() as ComponentRef | undefined; + const scrollViewRef = listRef.current.getNativeScrollRef(); // Try to scroll on underlying scrollView if available, fallback to usual listRef if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') { scrollViewRef.scrollToEnd({animated: false}); diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index a2764b32c88b..b58f5225d95d 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,7 +1,7 @@ import type FlatListRefType from '@components/FlashList/types'; +import type {FlashListRef} from '@shopify/flash-list'; import type {ReactNode, RefObject} from 'react'; -import type {FlatList} from 'react-native'; import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; @@ -35,7 +35,7 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); - const listRef = useRef(null); + const listRef = useRef>(null); useLayoutEffect(() => { registerListRef(listRef); diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index d7ef20d19184..7c9ed46aabe4 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -30,6 +30,9 @@ type Args = { /** The index of the action badge target report action in the sorted visible actions list (-1 if none) */ actionBadgeTargetIndex?: number; + /** Returns the list's visible index range as of now, or undefined before the list has a layout */ + getVisibleIndexRange?: () => {startIndex: number; endIndex: number} | undefined; + /** Whether the report is aligned to the top. When true, the "Latest messages" pill should never be shown. */ shouldBeAlignedToTop?: boolean; @@ -46,6 +49,7 @@ export default function useReportUnreadMessageScrollTracking({ unreadMarkerReportActionIndex, isInverted, actionBadgeTargetIndex = -1, + getVisibleIndexRange, shouldBeAlignedToTop = false, shouldDisablePillTracking = false, }: Args) { @@ -59,6 +63,7 @@ export default function useReportUnreadMessageScrollTracking({ isFocused: boolean; onUnreadActionVisible: () => void; actionBadgeTargetIndex: number; + getVisibleIndexRange: Args['getVisibleIndexRange']; }>({ reportID, unreadMarkerReportActionIndex, @@ -66,6 +71,7 @@ export default function useReportUnreadMessageScrollTracking({ isFocused: true, onUnreadActionVisible, actionBadgeTargetIndex, + getVisibleIndexRange, }); // We want to save the updated value on ref to use it in onViewableItemsChanged // because FlatList requires the callback to be stable and we cannot add a dependency on the useCallback. @@ -82,6 +88,10 @@ export default function useReportUnreadMessageScrollTracking({ ref.current.onUnreadActionVisible = onUnreadActionVisible; }, [onUnreadActionVisible]); + useEffect(() => { + ref.current.getVisibleIndexRange = getVisibleIndexRange; + }, [getVisibleIndexRange]); + /** * Show/hide the latest message pill when user is scrolling back/forth in the history of messages. */ @@ -163,7 +173,9 @@ export default function useReportUnreadMessageScrollTracking({ 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; + // FlashList reports a snapshot taken up to 250ms earlier, which can predate the new row's measured layout, so prefer the live range + const visibleRange = ref.current.getVisibleIndexRange?.(); + const isAbove = isInverted ? badgeTargetIndex > (visibleRange?.endIndex ?? maxIndex) : badgeTargetIndex < (visibleRange?.startIndex ?? minIndex); setIsActionBadgeAboveViewport(isAbove); } else { setIsActionBadgeAboveViewport(false); diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index e1cc995901d3..d030d450fa79 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -494,5 +494,38 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isActionBadgeAboveViewport).toBe(true); }); + + it('keeps the action badge hidden when the live range shows the target despite stale viewable items', () => { + // Given a live range that contains the target, as after a sent message shifts it from index 2 to 3 + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: true, + actionBadgeTargetIndex: 3, + getVisibleIndexRange: () => ({startIndex: 0, endIndex: 3}), + }), + ); + + // When FlashList reports a snapshot taken before the shift, which ends at index 2 + act(() => { + result.current.onViewableItemsChanged({ + viewableItems: [ + {index: 0, key: 'reportActions_0', isViewable: true, item: {}}, + {index: 1, key: 'reportActions_1', isViewable: true, item: {}}, + {index: 2, key: 'reportActions_2', isViewable: true, item: {}}, + ], + changed: [], + }); + }); + + // Then the live range wins and the pill does not flash + expect(result.current.isActionBadgeAboveViewport).toBe(false); + }); }); });