From e42c4c282b42d321da443699a2f5e4170d8917a4 Mon Sep 17 00:00:00 2001 From: aimane-chnaif Date: Fri, 23 Oct 2026 10:04:39 +0100 Subject: [PATCH 1/5] Recompute viewable items when the badge target changes --- src/components/FlashList/types.ts | 4 ++-- .../MoneyRequestReportUnifiedList.tsx | 5 ++--- src/hooks/useReportActionsScroll.ts | 3 ++- .../useReportScrollManager/index.native.ts | 6 +----- src/pages/inbox/ActionListContext.tsx | 4 ++-- .../useReportUnreadMessageScrollTracking.ts | 20 ++++++++++++++++++- 6 files changed, 28 insertions(+), 14 deletions(-) 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 a1c1a466fc5c..82bba64cb9f1 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 c8d05b3521d5..ca7511e8eb8f 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -160,7 +160,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; @@ -238,6 +238,7 @@ function useReportActionsScroll({ scrollOffsetRef.current = event.nativeEvent.contentOffset.y; }, actionBadgeTargetIndex, + recomputeViewableItems: () => getListRef()?.current?.recomputeViewableItems(), 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 0b6451b97d23..93a5787b788e 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -5,7 +5,7 @@ import type {RefObject} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; type Args = { reportID: string; @@ -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; + /** Makes the list report its viewable items again, even when the visible positions did not change */ + recomputeViewableItems?: () => void; + /** 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, + recomputeViewableItems, shouldBeAlignedToTop = false, shouldDisablePillTracking = false, }: Args) { @@ -184,9 +188,23 @@ export default function useReportUnreadMessageScrollTracking({ } }, [onViewableItemsChanged, unreadMarkerReportActionIndex]); + const recomputeListViewableItems = useEffectEvent(() => { + if (!recomputeViewableItems) { + return false; + } + recomputeViewableItems(); + return true; + }); + // When actionBadgeTargetIndex changes, recalculate visibility useEffect(() => { ref.current.actionBadgeTargetIndex = actionBadgeTargetIndex; + + // Inserted rows leave the cached viewable indexes stale, and the list stays silent when the visible positions are + // unchanged, so ask it to report fresh items instead of flashing the pill from the stale ones + if (actionBadgeTargetIndex !== -1 && recomputeListViewableItems()) { + return; + } onViewableItemsChanged({viewableItems: ref.current.previousViewableItems, changed: []}); }, [onViewableItemsChanged, actionBadgeTargetIndex]); From 69e7a7e6317f72b63a278dea72ab899c6f4effe5 Mon Sep 17 00:00:00 2001 From: aimane-chnaif Date: Sun, 25 Oct 2026 16:58:20 +0000 Subject: [PATCH 2/5] Check the live visible range before showing the action pill --- src/hooks/useReportActionsScroll.ts | 8 ++++++++ .../report/useReportUnreadMessageScrollTracking.ts | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 8542b034ae2e..b93cdc63c6dc 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -244,6 +244,14 @@ function useReportActionsScroll({ }, actionBadgeTargetIndex, recomputeViewableItems: () => getListRef()?.current?.recomputeViewableItems(), + 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; + } + return list.computeVisibleIndices(); + }, shouldBeAlignedToTop, }); diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 93a5787b788e..8dd933051799 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -33,6 +33,9 @@ type Args = { /** Makes the list report its viewable items again, even when the visible positions did not change */ recomputeViewableItems?: () => void; + /** 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; @@ -50,6 +53,7 @@ export default function useReportUnreadMessageScrollTracking({ isInverted, actionBadgeTargetIndex = -1, recomputeViewableItems, + getVisibleIndexRange, shouldBeAlignedToTop = false, shouldDisablePillTracking = false, }: Args) { @@ -63,6 +67,7 @@ export default function useReportUnreadMessageScrollTracking({ isFocused: boolean; onUnreadActionVisible: () => void; actionBadgeTargetIndex: number; + getVisibleIndexRange: Args['getVisibleIndexRange']; }>({ reportID, unreadMarkerReportActionIndex, @@ -70,6 +75,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. @@ -86,6 +92,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. */ @@ -167,7 +177,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); From 19e38e6b74aa7016f86e6217914882f2870a1702 Mon Sep 17 00:00:00 2001 From: aimane-chnaif Date: Sun, 25 Oct 2026 17:12:15 +0000 Subject: [PATCH 3/5] Drop recomputeViewableItems now that the pill reads the live range --- src/hooks/useReportActionsScroll.ts | 1 - .../useReportUnreadMessageScrollTracking.ts | 20 +------------------ 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index b93cdc63c6dc..307e31c49215 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -243,7 +243,6 @@ function useReportActionsScroll({ scrollOffsetRef.current = event.nativeEvent.contentOffset.y; }, actionBadgeTargetIndex, - recomputeViewableItems: () => getListRef()?.current?.recomputeViewableItems(), getVisibleIndexRange: () => { const list = getListRef()?.current; // getLayout is the one accessor that does not throw before FlashList has laid out diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 8dd933051799..38c53959854a 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -5,7 +5,7 @@ import type {RefObject} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; type Args = { reportID: string; @@ -30,9 +30,6 @@ type Args = { /** The index of the action badge target report action in the sorted visible actions list (-1 if none) */ actionBadgeTargetIndex?: number; - /** Makes the list report its viewable items again, even when the visible positions did not change */ - recomputeViewableItems?: () => void; - /** Returns the list's visible index range as of now, or undefined before the list has a layout */ getVisibleIndexRange?: () => {startIndex: number; endIndex: number} | undefined; @@ -52,7 +49,6 @@ export default function useReportUnreadMessageScrollTracking({ unreadMarkerReportActionIndex, isInverted, actionBadgeTargetIndex = -1, - recomputeViewableItems, getVisibleIndexRange, shouldBeAlignedToTop = false, shouldDisablePillTracking = false, @@ -200,23 +196,9 @@ export default function useReportUnreadMessageScrollTracking({ } }, [onViewableItemsChanged, unreadMarkerReportActionIndex]); - const recomputeListViewableItems = useEffectEvent(() => { - if (!recomputeViewableItems) { - return false; - } - recomputeViewableItems(); - return true; - }); - // When actionBadgeTargetIndex changes, recalculate visibility useEffect(() => { ref.current.actionBadgeTargetIndex = actionBadgeTargetIndex; - - // Inserted rows leave the cached viewable indexes stale, and the list stays silent when the visible positions are - // unchanged, so ask it to report fresh items instead of flashing the pill from the stale ones - if (actionBadgeTargetIndex !== -1 && recomputeListViewableItems()) { - return; - } onViewableItemsChanged({viewableItems: ref.current.previousViewableItems, changed: []}); }, [onViewableItemsChanged, actionBadgeTargetIndex]); From eeb91e305a5df6bf811d3c3526676833cbd295e9 Mon Sep 17 00:00:00 2001 From: aimane-chnaif Date: Sun, 25 Oct 2026 17:20:19 +0000 Subject: [PATCH 4/5] Ignore an empty live range --- src/hooks/useReportActionsScroll.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 307e31c49215..9e388380c009 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -249,7 +249,9 @@ function useReportActionsScroll({ if (!list?.getLayout(0)) { return undefined; } - return list.computeVisibleIndices(); + 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, }); From af5f114d3f1f84dfe81d053c9d0f77d127b36644 Mon Sep 17 00:00:00 2001 From: aimane-chnaif Date: Sun, 25 Oct 2026 17:20:25 +0000 Subject: [PATCH 5/5] test the live range path --- ...seReportUnreadMessageScrollTrackingTest.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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); + }); }); });