diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 30ec578d6e96..db7bd34d1b8c 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -1,7 +1,7 @@ import type {FlashListProps} from '@shopify/flash-list'; import React from 'react'; +import type FlatListRefType from '@components/FlashList/types'; import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey'; -import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; import FlashList from '..'; import CellRendererComponent from './CellRendererComponent'; diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts new file mode 100644 index 000000000000..cf7718d3d148 --- /dev/null +++ b/src/components/FlashList/types.ts @@ -0,0 +1,7 @@ +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; + +export default FlatListRefType; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 4105ad51d902..8fd07ceca45a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -2,8 +2,8 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import type {FlatList, LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, View} from 'react-native'; import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import ScrollView from '@components/ScrollView'; @@ -50,6 +50,7 @@ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; +import {useActionListContext} from '@pages/inbox/ActionListContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; @@ -57,7 +58,6 @@ import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexConte import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import variables from '@styles/variables'; import {getOlderActions, openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -196,7 +196,17 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const lastAction = visibleReportActions.at(-1); - const {scrollOffsetRef} = useContext(ActionListContext); + const {scrollOffsetRef, registerListRef} = useActionListContext(); + + // Own the list ref locally and publish it so handlers resolve it via `getListRef()`. Use a + // layout effect so the ref is registered at commit — before any layout-time scroll handler + // reads it via `getListRef()` — rather than after paint. + const listRef = useRef(null); + useLayoutEffect(() => { + registerListRef(listRef); + return () => registerListRef(null); + }, [registerListRef]); + const scrollingVerticalBottomOffset = useRef(0); const scrollingVerticalTopOffset = useRef(0); const wrapperViewRef = useRef(null); @@ -754,7 +764,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) keyboardShouldPersistTaps="handled" onScroll={trackVerticalScrolling} contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} - ref={reportScrollManager.ref} + ref={listRef} ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState removeClippedSubviews={false} initialScrollKey={linkedReportActionID} diff --git a/src/components/Reactions/EmojiReactionBubble.tsx b/src/components/Reactions/EmojiReactionBubble.tsx index 41028c8eafe7..2f6ae2686fac 100644 --- a/src/components/Reactions/EmojiReactionBubble.tsx +++ b/src/components/Reactions/EmojiReactionBubble.tsx @@ -5,7 +5,7 @@ import Text from '@components/Text'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ReactionListEvent} from '@pages/inbox/ReportScreenContext'; +import type {ReactionListEvent} from '@pages/inbox/ReactionListContext'; import CONST from '@src/CONST'; type EmojiReactionBubbleProps = { diff --git a/src/components/Reactions/ReportActionReactionBubble.tsx b/src/components/Reactions/ReportActionReactionBubble.tsx index f7685579e751..ff85afc4d956 100644 --- a/src/components/Reactions/ReportActionReactionBubble.tsx +++ b/src/components/Reactions/ReportActionReactionBubble.tsx @@ -2,8 +2,8 @@ import React, {useContext, useRef} from 'react'; import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; -import {ReactionListContext} from '@pages/inbox/ReportScreenContext'; -import type {ReactionListAnchor, ReactionListEvent} from '@pages/inbox/ReportScreenContext'; +import {ReactionListContext} from '@pages/inbox/ReactionListContext'; +import type {ReactionListAnchor, ReactionListEvent} from '@pages/inbox/ReactionListContext'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import EmojiReactionBubble from './EmojiReactionBubble'; import ReactionTooltipContent from './ReactionTooltipContent'; diff --git a/src/hooks/useActionListContextValue.ts b/src/hooks/useActionListContextValue.ts deleted file mode 100644 index ba73ff804cc0..000000000000 --- a/src/hooks/useActionListContextValue.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {useRef} from 'react'; -import type {FlatList} from 'react-native'; -import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext'; - -function useActionListContextValue(): ActionListContextType { - const flatListRef = useRef(null); - const scrollPositionRef = useRef({}); - const scrollOffsetRef = useRef(0); - - return {flatListRef, scrollPositionRef, scrollOffsetRef}; -} - -export default useActionListContextValue; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 660da9c66344..5cf027b1bad5 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import {useContext, useEffect, useEffectEvent, useState} from 'react'; +import {useEffect, useEffectEvent, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; @@ -12,9 +12,9 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isReportPreviewAction, isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; import {getReportLastVisibleActionCreated, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import {useActionListContext} from '@pages/inbox/ActionListContext'; import useReportActionsNewActionLiveTail from '@pages/inbox/report/useReportActionsNewActionLiveTail'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {openReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -74,9 +74,6 @@ type UseReportActionsScrollParams = { }; type UseReportActionsScrollResult = { - /** Ref to attach to the inverted FlashList */ - listRef: ReturnType['ref']; - /** Scroll handler that tracks vertical offset and floating counter visibility */ trackVerticalScrolling: (event: NativeSyntheticEvent | undefined) => void; @@ -132,7 +129,7 @@ function useReportActionsScroll({ setTreatAsNoPaginationAnchor, }: UseReportActionsScrollParams): UseReportActionsScrollResult { const reportScrollManager = useReportScrollManager(); - const {scrollOffsetRef} = useContext(ActionListContext); + const {scrollOffsetRef} = useActionListContext(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; const backTo = route?.params?.backTo; @@ -355,7 +352,6 @@ function useReportActionsScroll({ }, [shouldFocusToTopOnMount, shouldAutoscrollToBottom, prevHasOnceLoadedReportActions, reportLoadingState?.hasOnceLoadedReportActions]); return { - listRef: reportScrollManager.ref, trackVerticalScrolling, onViewableItemsChanged, isFloatingMessageCounterVisible, diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 6a6f34ef42b7..1e9a3f8e513a 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,20 +1,22 @@ -import {useContext} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import {useActionListContext} from '@pages/inbox/ActionListContext'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { - const {flatListRef, scrollPositionRef} = useContext(ActionListContext); + const {getListRef, scrollPositionRef} = useActionListContext(); /** - * Scroll to the provided index. + * Scroll to the provided index. `isEditing` is accepted for signature parity with the web + * implementation but is a no-op here, matching the previous native behavior. `animated` + * defaults to `false` to match FlashList's default (the previous native call omitted it). */ - const scrollToIndex = (index: number) => { - if (!flatListRef?.current) { + const scrollToIndex = (index: number, {animated = false}: {isEditing?: boolean; animated?: boolean} = {}) => { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToIndex({index}); + listRef.current.scrollToIndex({index, animated}); }; /** @@ -22,40 +24,43 @@ function useReportScrollManager(): ReportScrollManagerData { * When FlatList is inverted it's "bottom" is really it's top */ const scrollToBottom = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } scrollPositionRef.current = {offset: 0}; - flatListRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToIndex({animated: false, index: 0}); }; /** * Scroll to the end of the FlatList. */ const scrollToEnd = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - const scrollViewRef = flatListRef.current.getNativeScrollRef(); + const scrollViewRef = listRef.current.getNativeScrollRef?.() as ScrollView | undefined; // Try to scroll on underlying scrollView if available, fallback to usual listRef - if (scrollViewRef && 'scrollToEnd' in scrollViewRef) { - (scrollViewRef as ScrollView).scrollToEnd({animated: false}); + if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') { + scrollViewRef.scrollToEnd({animated: false}); return; } - flatListRef.current.scrollToEnd({animated: false}); + listRef.current.scrollToEnd({animated: false}); }; const scrollToOffset = (offset: number) => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToOffset({offset, animated: false}); + listRef.current.scrollToOffset({offset, animated: false}); }; - return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index bde0ca00a493..edb82da029a2 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -1,19 +1,20 @@ -import {useContext} from 'react'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import {useActionListContext} from '@pages/inbox/ActionListContext'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { - const {flatListRef} = useContext(ActionListContext); + const {getListRef} = useActionListContext(); /** - * Scroll to the provided index. On non-native implementations we do not want to scroll when we are scrolling because + * Scroll to the provided index. When `isEditing` is set we skip the scroll so the list doesn't + * jump while the user is editing a message. */ - const scrollToIndex = (index: number, isEditing?: boolean) => { - if (!flatListRef?.current || isEditing) { + const scrollToIndex = (index: number, {isEditing = false, animated = true}: {isEditing?: boolean; animated?: boolean} = {}) => { + const listRef = getListRef(); + if (!listRef?.current || isEditing) { return; } - flatListRef.current.scrollToIndex({index, animated: true}); + listRef.current.scrollToIndex({index, animated}); }; /** @@ -21,33 +22,36 @@ function useReportScrollManager(): ReportScrollManagerData { * When FlatList is inverted it's "bottom" is really it's top */ const scrollToBottom = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToIndex({animated: false, index: 0}); }; /** * Scroll to the end of the FlatList. */ const scrollToEnd = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToEnd({animated: false}); + listRef.current.scrollToEnd({animated: false}); }; const scrollToOffset = (offset: number) => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToOffset({animated: true, offset}); + listRef.current.scrollToOffset({animated: true, offset}); }; - return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index 44ccda75e55f..a7c804b5cc44 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -1,8 +1,11 @@ -import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; - type ReportScrollManagerData = { - ref: FlatListRefType; - scrollToIndex: (index: number, isEditing?: boolean) => void; + /** + * Scroll to a list index. `isEditing` suppresses the scroll (web only, defaults to `false`). + * When `animated` is omitted each platform keeps its prior default: web animates, native jumps + * instantly. ReportActionItemMessageEdit's Android Chrome keyboard hack passes `{animated: false}` + * to scroll instantly when the edit composer gains focus and the soft keyboard shifts the viewport. + */ + scrollToIndex: (index: number, options?: {isEditing?: boolean; animated?: boolean}) => void; scrollToBottom: () => void; scrollToEnd: () => void; scrollToOffset: (offset: number) => void; diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 003da7726096..a1dfcbe68b05 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -10,7 +10,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchResultsContext} from '@components/Search/SearchContext'; import useShowSuperWideRHPVersion from '@components/WideRHPContextProvider/useShowSuperWideRHPVersion'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; -import useActionListContextValue from '@hooks/useActionListContextValue'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDismissOnMoneyRequestReportRemoval from '@hooks/useDismissOnMoneyRequestReportRemoval'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -37,10 +36,10 @@ import {isMoneyRequestReportPendingDeletion, isValidReportIDFromPath} from '@lib import {cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; import {doesDeleteNavigateBackUrlIncludeDuplicatesReview, getParentReportActionDeletionStatus, hasLoadedReportActions, isThreadReportDeleted} from '@libs/TransactionNavigationUtils'; import Navigation from '@navigation/Navigation'; +import {ActionListContextProvider} from '@pages/inbox/ActionListContext'; import ReactionListWrapper from '@pages/inbox/ReactionListWrapper'; import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; import useClearReportActionDraftsOnReportChange from '@pages/inbox/report/useClearReportActionDraftsOnReportChange'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {clearDeleteTransactionNavigateBackUrl, createTransactionThreadReport, openReport, updateLastVisitTime} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -116,8 +115,6 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); - const actionListValue = useActionListContextValue(); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const {transactions: allReportTransactions, violations: allReportViolations} = useTransactionsAndViolationsForReport(reportIDFromRoute); @@ -380,7 +377,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { effectiveTransactionThreadReportID={effectiveTransactionThreadReportID} > - + - + ); diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx new file mode 100644 index 000000000000..5c3d389c5cc1 --- /dev/null +++ b/src/pages/inbox/ActionListContext.tsx @@ -0,0 +1,58 @@ +import React, {createContext, useContext, useRef} from 'react'; +import type {ReactNode, RefObject} from 'react'; +import type FlatListRefType from '@components/FlashList/types'; + +type ScrollPosition = {offset?: number}; + +type ActionListContextType = { + scrollPositionRef: RefObject; + scrollOffsetRef: RefObject; + + /** Snapshot of the persisted scroll offset. Safe to call during render (e.g. a useState initializer) to restore mount-time scroll state. */ + getScrollOffset: () => number; + + /** Each list publishes its locally-owned ref on mount; pass `null` to clear on unmount. */ + registerListRef: (ref: FlatListRefType) => void; + + /** Reads the currently registered list ref. Call from handlers only, never during render. */ + getListRef: () => FlatListRefType; +}; + +const ActionListContext = createContext({ + scrollPositionRef: {current: {}}, + scrollOffsetRef: {current: 0}, + getScrollOffset: () => 0, + registerListRef: () => {}, + getListRef: () => null, +}); + +function useActionListContext() { + return useContext(ActionListContext); +} + +/** Owns the action-list context value so screens don't wire it up themselves. */ +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 scrollPositionRef = useRef({}); + const scrollOffsetRef = useRef(0); + + const value: ActionListContextType = { + scrollPositionRef, + scrollOffsetRef, + getScrollOffset: () => scrollOffsetRef.current, + registerListRef: (ref) => { + listRefHolder.current = ref; + }, + getListRef: () => listRefHolder.current, + }; + + // The value is only stable refs and ref accessors — none of it triggers re-renders — so a + // single context is intentional; splitting state/actions into two providers would add ceremony + // for no benefit. + // eslint-disable-next-line rulesdir/context-provider-split-values + return {children}; +} + +export {ActionListContext, ActionListContextProvider, useActionListContext}; diff --git a/src/pages/inbox/ReportScreenContext.ts b/src/pages/inbox/ReactionListContext.ts similarity index 50% rename from src/pages/inbox/ReportScreenContext.ts rename to src/pages/inbox/ReactionListContext.ts index 9102cdba56d3..35d9a2106b82 100644 --- a/src/pages/inbox/ReportScreenContext.ts +++ b/src/pages/inbox/ReactionListContext.ts @@ -1,7 +1,7 @@ -import type {RefObject, SyntheticEvent} from 'react'; +import type {SyntheticEvent} from 'react'; import {createContext} from 'react'; // eslint-disable-next-line no-restricted-imports -import type {FlatList, GestureResponderEvent, Text, View} from 'react-native'; +import type {GestureResponderEvent, Text, View} from 'react-native'; type ReactionListAnchor = View | Text | HTMLDivElement | null; @@ -13,22 +13,11 @@ type ReactionListContextType = { isActiveReportAction: (reportActionID: number | string) => boolean; }; -type FlatListRefType = RefObject | null> | null; - -type ScrollPosition = {offset?: number}; - -type ActionListContextType = { - flatListRef: FlatListRefType; - scrollPositionRef: RefObject; - scrollOffsetRef: RefObject; -}; - -const ActionListContext = createContext({flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}}); const ReactionListContext = createContext({ showReactionList: () => {}, hideReactionList: () => {}, isActiveReportAction: () => false, }); -export {ActionListContext, ReactionListContext}; -export type {ReactionListContextType, ActionListContextType, FlatListRefType, ReactionListAnchor, ReactionListEvent, ScrollPosition}; +export {ReactionListContext}; +export type {ReactionListContextType, ReactionListAnchor, ReactionListEvent}; diff --git a/src/pages/inbox/ReactionListWrapper.tsx b/src/pages/inbox/ReactionListWrapper.tsx index 17bbf9573e86..e240018035ca 100644 --- a/src/pages/inbox/ReactionListWrapper.tsx +++ b/src/pages/inbox/ReactionListWrapper.tsx @@ -1,9 +1,9 @@ import React, {useEffect, useRef, useState} from 'react'; import type {SyntheticEvent} from 'react'; import {Dimensions} from 'react-native'; +import {ReactionListContext} from './ReactionListContext'; +import type {ReactionListAnchor, ReactionListContextType, ReactionListEvent} from './ReactionListContext'; import PopoverReactionList from './report/ReactionList/PopoverReactionList'; -import {ReactionListContext} from './ReportScreenContext'; -import type {ReactionListAnchor, ReactionListContextType, ReactionListEvent} from './ReportScreenContext'; type AnchorPosition = {horizontal: number; vertical: number}; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 2f8b831522b8..f9298e3a17d1 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -6,7 +6,6 @@ import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; -import useActionListContextValue from '@hooks/useActionListContextValue'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -21,6 +20,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import AccountManagerBanner from './AccountManagerBanner'; +import {ActionListContextProvider} from './ActionListContext'; import {AgentZeroStatusProvider} from './AgentZeroStatusContext'; import {ConciergeDraftProvider} from './ConciergeDraftContext'; import DeleteTransactionNavigateBackHandler from './DeleteTransactionNavigateBackHandler'; @@ -40,7 +40,6 @@ import ReportLifecycleHandler from './ReportLifecycleHandler'; import ReportNavigateAwayHandler from './ReportNavigateAwayHandler'; import ReportNotFoundGuard from './ReportNotFoundGuard'; import ReportRouteParamHandler from './ReportRouteParamHandler'; -import {ActionListContext} from './ReportScreenContext'; import type ReportScreenNavigationProps from './types'; import WideRHPReceiptPanel from './WideRHPReceiptPanel'; @@ -85,8 +84,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { useFlushDeferredWriteOnFocus(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL); - const actionListValue = useActionListContextValue(); - const [reportPendingActionAndErrors] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, { selector: (r) => ({ reportPendingAction: r?.pendingFields?.createReport ?? r?.pendingFields?.reportName, @@ -106,7 +103,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { return ( - + - + ); diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx index ff19cffd0248..f729ff0ef845 100644 --- a/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx +++ b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx @@ -4,7 +4,7 @@ import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import {getEmojiReactionDetails, mergeReactionsByEmoji} from '@libs/EmojiUtils'; -import type {ReactionListAnchor} from '@pages/inbox/ReportScreenContext'; +import type {ReactionListAnchor} from '@pages/inbox/ReactionListContext'; import ONYXKEYS from '@src/ONYXKEYS'; import {multiPersonalDetailsSelector} from '@src/selectors/PersonalDetails'; import type {PersonalDetails} from '@src/types/onyx'; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 059293fa2ab3..e80ef98ae7a6 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -1,5 +1,4 @@ import {Str} from 'expensify-common'; -import {useContext} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAncestors from '@hooks/useAncestors'; @@ -15,8 +14,8 @@ import {rand64} from '@libs/NumberUtils'; import {addDomainToShortMention} from '@libs/ParsingUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import {generateAccountID} from '@libs/UserUtils'; +import {useActionListContext} from '@pages/inbox/ActionListContext'; import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {setIsComposerFullSize} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -41,7 +40,7 @@ function useComposerSubmit(reportID: string) { const {isSendDisabled, debouncedCommentMaxLengthValidation} = useComposerSendState(); const {isEditingInComposer, effectiveDraft, didResetComposerHeightWhileEditing, editingState} = useComposerEditState(); const {publishDraft, setDidResetComposerHeightWhileEditing} = useComposerEditActions(); - const {scrollOffsetRef} = useContext(ActionListContext); + const {scrollOffsetRef} = useActionListContext(); const {report, effectiveTransactionThreadReportID} = useComposerReportData(reportID); const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 5593b90aeb17..51a6878d39e8 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -53,7 +53,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. if (shouldScrollToLastMessage) { - reportScrollManager.scrollToIndex(0, false); + reportScrollManager.scrollToIndex(0); } } diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 26d1cb98e5b0..2debd16d117f 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -65,7 +65,7 @@ import { } from '@libs/ReportUtils'; import SelectionScraper from '@libs/SelectionScraper'; import shouldBreakAccessibilityGrouping from '@libs/shouldBreakAccessibilityGrouping'; -import {ReactionListContext} from '@pages/inbox/ReportScreenContext'; +import {ReactionListContext} from '@pages/inbox/ReactionListContext'; import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; import {hideEmojiPicker, isActive} from '@userActions/EmojiPickerAction'; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 399941e2e4f9..e77b3d2e2fa9 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -432,8 +432,8 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy ReportActionComposeFocusManager.editComposerRef.current = composerRef.current; } - if (isMobileChrome() && reportScrollManager.ref?.current) { - reportScrollManager.ref.current.scrollToIndex({index, animated: false}); + if (isMobileChrome()) { + reportScrollManager.scrollToIndex(index, {animated: false}); } // Clear active report action when another action gets focused diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 1a6dc76dca3f..ca45eacab961 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,8 +1,8 @@ import {useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import type {ListRenderItemInfo} from '@shopify/flash-list'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; +import type {FlatList, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; @@ -46,9 +46,9 @@ import { } from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import {useActionListContext} from '@pages/inbox/ActionListContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -85,6 +85,18 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } +/** + * Precompute a reportActionID -> index map so renderItem can resolve the real index in O(1) + * instead of scanning the actions list with indexOf on every render. + */ +function getActionIndexMap(actions: OnyxTypes.ReportAction[]): Map { + const map = new Map(); + for (const [i, action] of actions.entries()) { + map.set(action.reportActionID, i); + } + return map; +} + /** * Renders the report-actions list. Reads its data from `ReportActionsListStateContext` / `ReportActionsListActionsContext` and holds the * UI-close hooks (`useUnreadMarker` / `useMarkAsRead` / `useReportActionsScroll`). `ReportActionsSkeletonGuard` @@ -137,39 +149,45 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); - const reportAttributesSelector = useCallback( - (value: OnyxEntry) => { - const attrs = value?.reports?.[reportID]; - if (!attrs) { - return undefined; - } - return { - actionBadge: attrs.actionBadge, - actionTargetReportActionID: attrs.actionTargetReportActionID, - brickRoadStatus: attrs.brickRoadStatus, - }; - }, - [reportID], - ); + const reportAttributesSelector = (value: OnyxEntry) => { + const attrs = value?.reports?.[reportID]; + if (!attrs) { + return undefined; + } + return { + actionBadge: attrs.actionBadge, + actionTargetReportActionID: attrs.actionTargetReportActionID, + brickRoadStatus: attrs.brickRoadStatus, + }; + }; const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { selector: reportAttributesSelector, }); const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - const stableReportSelector = useCallback((reportEntry: OnyxEntry) => getStableReportSelector(reportEntry), []); - const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: stableReportSelector}); - const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: stableReportSelector}); + const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); + const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); const linkedReportActionID = reportActionIDFromRoute; - const {scrollOffsetRef} = useContext(ActionListContext); + const {registerListRef, getScrollOffset} = useActionListContext(); + + // Own the list ref locally and publish it so handlers resolve it via `getListRef()`. Use a + // layout effect so the ref is registered at commit — before the list's `onLayout` fires and + // calls into `getListRef()` — rather than after paint, which could leave handlers reading null. + const listRef = useRef(null); + useLayoutEffect(() => { + registerListRef(listRef); + return () => registerListRef(null); + }, [registerListRef]); + const {draftReportAction, hasActiveDraft, isDraftPendingCompletion} = useConciergeDraft(); const {clearDraft} = useConciergeDraftActions(); const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; const onShowPreviousMessages = handleShowPreviousMessages; - const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ reportID, @@ -189,7 +207,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) hasNewerActions, }); - const renderedVisibleReportActions = useMemo(() => { + const getRenderedVisibleReportActions = () => { if (!draftReportAction) { return sortedVisibleReportActions; } @@ -219,7 +237,8 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; visibleReportActionsWithDraft.push(draftReportAction); return visibleReportActionsWithDraft; - }, [sessionStartTime, draftReportAction, isDraftPendingCompletion, showHiddenHistory, sortedVisibleReportActions]); + }; + const renderedVisibleReportActions = getRenderedVisibleReportActions(); const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; @@ -235,16 +254,16 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) - const actionBadgeTargetIndex = useMemo(() => { + const getActionBadgeTargetIndex = () => { const targetID = reportAttributes?.actionTargetReportActionID; if (!targetID) { return -1; } return renderedVisibleReportActions.findIndex((action) => action.reportActionID === targetID); - }, [reportAttributes?.actionTargetReportActionID, renderedVisibleReportActions]); + }; + const actionBadgeTargetIndex = getActionBadgeTargetIndex(); const { - listRef, trackVerticalScrolling, onViewableItemsChanged, isFloatingMessageCounterVisible, @@ -302,18 +321,15 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; + const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); + /** * Thread's divider line should hide when the first chat in the thread is marked as unread. * This is so that it will not be conflicting with header's separator line. */ - const shouldHideThreadDividerLine = useMemo( - (): boolean => getFirstVisibleReportActionID(sortedReportActions, isOffline) === unreadMarkerReportActionID, - [sortedReportActions, isOffline, unreadMarkerReportActionID], - ); + const shouldHideThreadDividerLine = firstVisibleReportActionID === unreadMarkerReportActionID; - const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(sortedReportActions, isOffline), [sortedReportActions, isOffline]); - - const shouldUseThreadDividerLine = useMemo(() => { + const getShouldUseThreadDividerLine = () => { const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { @@ -329,108 +345,67 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) } return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); - }, [parentReportAction, renderedVisibleReportActions, report]); - - // Precompute a reportActionID -> index map so renderItem can resolve the real index in O(1) - // instead of scanning renderedVisibleReportActions with indexOf on every render. - const actionIndexMap = useMemo(() => { - const map = new Map(); - for (const [i, action] of renderedVisibleReportActions.entries()) { - map.set(action.reportActionID, i); - } - return map; - }, [renderedVisibleReportActions]); - - const renderItem = useCallback( - ({item: reportAction, index}: ListRenderItemInfo) => { - // Use the action's actual index in sortedVisibleReportActions rather than the FlashList-provided index, - // because useFlashListScrollKey may slice the data for deep-link scroll positioning, making the - // FlashList index offset from the full array and causing wrong displayAsGroup computation. - const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index; - const shouldDisableContextMenuForConciergeDraft = draftReportActionID === reportAction.reportActionID; - - return ( - - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - shouldUseThreadDividerLine={shouldUseThreadDividerLine} - isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + }; + const shouldUseThreadDividerLine = getShouldUseThreadDividerLine(); + + const actionIndexMap = getActionIndexMap(renderedVisibleReportActions); + + const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { + // Use the action's actual index in sortedVisibleReportActions rather than the FlashList-provided index, + // because useFlashListScrollKey may slice the data for deep-link scroll positioning, making the + // FlashList index offset from the full array and causing wrong displayAsGroup computation. + const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index; + const shouldDisableContextMenuForConciergeDraft = draftReportActionID === reportAction.reportActionID; + + return ( + + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + shouldUseThreadDividerLine={shouldUseThreadDividerLine} + isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + {!!reportStable?.reportID && ( + - {!!reportStable?.reportID && ( - - )} - - ); - }, - [ - actionIndexMap, - draftReportActionID, - firstVisibleReportActionID, - hasPreviousMessages, - isOffline, - linkedReportActionID, - onShowPreviousMessages, - parentReportAction, - parentReportActionForTransactionThread, - isHarvestCreatedExpenseReportAction, - renderedVisibleReportActions, - reportStable, - chatReportStable, - shouldHideThreadDividerLine, - shouldUseThreadDividerLine, - showHiddenHistory, - transactionThreadReport, - unreadMarkerReportActionID, - ], - ); + )} + + ); + }; // Native mobile does not render updates flatlist the changes even though component did update called. // To notify there something changes we can use extraData prop to flatlist - const extraData = useMemo( - () => [shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, isArchivedNonExpenseReport(report, isReportArchived), draftReportActionID, draftMessageHTML], - [draftMessageHTML, draftReportActionID, unreadMarkerReportActionID, shouldUseNarrowLayout, report, isReportArchived], - ); + const extraData = [shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, isArchivedNonExpenseReport(report, isReportArchived), draftReportActionID, draftMessageHTML]; - const listHeaderComponent = useMemo( - () => ( - - ), - [hasActiveDraft, reportID], + const listHeaderComponent = ( + ); const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const listFooterComponent = useMemo(() => { - if (!shouldShowOfflineSkeleton) { - return; - } - - return ; - }, [shouldShowOfflineSkeleton]); + const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; const shouldUseMarkAsDoneCopy = shouldShowMarkAsDone({ policy, @@ -525,8 +500,6 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) ); } -const MemoizedReportActionsListContent = memo(ReportActionsListContent); - /** * Public report-actions list. Thin composition that wraps the content in `ReportActionsSkeletonGuard`, * which owns the data pipeline + skeleton decision and only mounts the content once it is ready. @@ -534,7 +507,7 @@ const MemoizedReportActionsListContent = memo(ReportActionsListContent); function ReportActionsList({reportID, onLayout}: ReportActionsListProps) { return ( - diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 0d6213f2dd46..53fcf3e570d6 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -6,8 +6,9 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {setHasRadio} from '@libs/NetworkState'; +import {ActionListContext} from '@pages/inbox/ActionListContext'; +import {ReactionListContext} from '@pages/inbox/ReactionListContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; -import {ActionListContext, ReactionListContext} from '@pages/inbox/ReportScreenContext'; import {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; import ComposeProviders from '@src/components/ComposeProviders'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; @@ -38,7 +39,11 @@ beforeAll(() => ); const mockOnLayout = jest.fn(); -const mockRef = {current: null, flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}}; +// Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. +function buildActionListContextValue() { + return {scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, registerListRef: () => {}, getListRef: () => null}; +} +const actionListContextValue = buildActionListContextValue(); const mockReactionListContextValue = { showReactionList: () => {}, hideReactionList: () => {}, @@ -96,7 +101,7 @@ function ReportActionsListWrapper() { - + { + it('getListRef() returns null before any ref is registered', () => { + const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); + + expect(result.current.getListRef()).toBeNull(); + }); + + it('getListRef() returns the same ref object after registerListRef(ref)', () => { + const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); + const listRef: FlatListRefType = {current: null}; + + act(() => { + result.current.registerListRef(listRef); + }); + + expect(result.current.getListRef()).toBe(listRef); + }); + + it('getListRef() returns null again after registerListRef(null) (unmount path)', () => { + const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); + const listRef: FlatListRefType = {current: null}; + + act(() => { + result.current.registerListRef(listRef); + }); + expect(result.current.getListRef()).toBe(listRef); + + act(() => { + result.current.registerListRef(null); + }); + + expect(result.current.getListRef()).toBeNull(); + }); + + it('keeps scrollPositionRef / scrollOffsetRef stable across re-renders with their initial values', () => { + const {result, rerender} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); + + const firstScrollPositionRef = result.current.scrollPositionRef; + const firstScrollOffsetRef = result.current.scrollOffsetRef; + + expect(firstScrollPositionRef.current).toEqual({}); + expect(firstScrollOffsetRef.current).toBe(0); + + rerender({}); + + expect(result.current.scrollPositionRef).toBe(firstScrollPositionRef); + expect(result.current.scrollOffsetRef).toBe(firstScrollOffsetRef); + }); +}); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx new file mode 100644 index 000000000000..549ec07fcf19 --- /dev/null +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -0,0 +1,161 @@ +import {NavigationContainer} from '@react-navigation/native'; +import {act, render, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import type {RefObject} from 'react'; +import Onyx from 'react-native-onyx'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; +import {setHasRadio} from '@libs/NetworkState'; +import {ActionListContext} from '@pages/inbox/ActionListContext'; +import {ReactionListContext} from '@pages/inbox/ReactionListContext'; +import ReportActionsList from '@pages/inbox/report/ReportActionsList'; +import {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; +import ComposeProviders from '@src/components/ComposeProviders'; +import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; +import * as ReportTestUtils from '../utils/ReportTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +const THRESHOLD = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + +type ScrollEvent = {nativeEvent: {contentOffset: {y: number}}}; +type CapturedListProps = { + shouldMaintainVisibleContentPosition?: boolean; + onScroll?: (event: ScrollEvent) => void; +}; + +// Capture the props the list is rendered with so we can observe `shouldMaintainVisibleContentPosition`, +// which is `hasScrolledOverThreshold || shouldFocusToTopOnMount`. With no deep-link the latter is false, +// so the prop mirrors the boolean under test. +let capturedListProps: CapturedListProps = {}; +// Every value `shouldMaintainVisibleContentPosition` has held, in render order. `[0]` is the value on the +// list's very first render — the property that matters, since the boolean must be right before any effect runs. +let mockMvcpHistory: Array = []; +jest.mock('@components/FlashList/InvertedFlashList', () => { + const {forwardRef} = jest.requireActual('react'); + return { + __esModule: true, + default: forwardRef((props) => { + capturedListProps = props; + mockMvcpHistory.push(props.shouldMaintainVisibleContentPosition); + return null; + }), + }; +}); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useRoute: () => ({params: {}}), + useIsFocused: () => true, + }; +}); + +beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }), +); + +const TEST_USER_ACCOUNT_ID = 1; +const TEST_USER_LOGIN = 'test@test.com'; +const REPORT_ID = '1'; + +const mockReactionListContextValue = { + showReactionList: () => {}, + hideReactionList: () => {}, + isActiveReportAction: () => false, +}; + +const sortedReportActions = ReportTestUtils.getMockedSortedReportActions(10); +const reportActions: ReportActions = Object.fromEntries(sortedReportActions.map((action: ReportAction) => [action.reportActionID, action])); +const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, lastVisibleActionCreated: sortedReportActions.at(0)?.created}); + +// Built via a function so the value isn't an inline literal the context-split lint rule would flag. +function buildActionListContextValue(initialOffset: number) { + const scrollOffsetRef: RefObject = {current: initialOffset}; + return {scrollPositionRef: {current: {}}, scrollOffsetRef, getScrollOffset: () => scrollOffsetRef.current, registerListRef: () => {}, getListRef: () => null}; +} + +async function renderList(initialOffset: number) { + const actionListContextValue = buildActionListContextValue(initialOffset); + const utils = render( + + + + + + + + + , + ); + await waitFor(() => expect(capturedListProps.shouldMaintainVisibleContentPosition).toBeDefined()); + return utils; +} + +beforeEach(async () => { + capturedListProps = {}; + mockMvcpHistory = []; + setHasRadio(true); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + await act(async () => { + TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + await Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.DEFAULT); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, { + isLoadingInitialReportActions: false, + hasOnceLoadedReportActions: true, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, + }); + await waitForBatchedUpdates(); + }); +}); + +afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); +}); + +describe('ReportActionsList hasScrolledOverThreshold', () => { + it('enables maintainVisibleContentPosition 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(capturedListProps.shouldMaintainVisibleContentPosition).toBe(true); + }); + + it('leaves maintainVisibleContentPosition off on first render when mounted at the bottom (offset below threshold)', async () => { + await renderList(0); + + expect(capturedListProps.shouldMaintainVisibleContentPosition).toBe(false); + }); + + it('flips the flag as the user scrolls across the threshold', async () => { + await renderList(0); + expect(capturedListProps.shouldMaintainVisibleContentPosition).toBe(false); + + act(() => { + capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: THRESHOLD + 50}}}); + }); + expect(capturedListProps.shouldMaintainVisibleContentPosition).toBe(true); + + act(() => { + capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: 0}}}); + }); + expect(capturedListProps.shouldMaintainVisibleContentPosition).toBe(false); + }); +}); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 78297b145645..f1b00c9c8f4c 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -4,7 +4,7 @@ import type {ReactNode} from 'react'; import Onyx from 'react-native-onyx'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import type Navigation from '@libs/Navigation/Navigation'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import {ActionListContext} from '@pages/inbox/ActionListContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; @@ -26,11 +26,9 @@ jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameR // --- useReportScrollManager --- const mockScrollToBottom = jest.fn(); const mockScrollToIndex = jest.fn(); -const mockScrollManagerRef = {current: null}; jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ - ref: mockScrollManagerRef, scrollToBottom: mockScrollToBottom, scrollToIndex: mockScrollToIndex, scrollToEnd: jest.fn(), @@ -184,8 +182,13 @@ function buildParams(overrides: Partial = {}): ScrollParams { }; } +// Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. +function buildActionListContextValue() { + return {scrollPositionRef: {current: {}}, scrollOffsetRef: mockScrollOffsetRef, getScrollOffset: () => mockScrollOffsetRef.current, registerListRef: () => {}, getListRef: () => null}; +} + function wrapper({children}: {children: ReactNode}) { - return {children}; + return {children}; } async function renderScroll(overrides: Partial = {}) { @@ -584,7 +587,6 @@ describe('useReportActionsScroll', () => { expect(result.current.isFloatingMessageCounterVisible).toBe(true); expect(result.current.isActionBadgeAboveViewport).toBe(true); - expect(result.current.listRef).toBe(mockScrollManagerRef); result.current.trackVerticalScrolling(undefined); expect(mockTrackVerticalScrolling).toHaveBeenCalledWith(undefined); diff --git a/tests/unit/useReportScrollManagerTest.tsx b/tests/unit/useReportScrollManagerTest.tsx new file mode 100644 index 000000000000..9a2378e68318 --- /dev/null +++ b/tests/unit/useReportScrollManagerTest.tsx @@ -0,0 +1,116 @@ +import {act, renderHook} from '@testing-library/react-native'; +import type {ReactNode} from 'react'; +import type FlatListRefType from '@components/FlashList/types'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import {ActionListContext, useActionListContext} from '@pages/inbox/ActionListContext'; + +/** + * `useReportScrollManager` resolves the list ref via `getListRef()` at call time (never captured at + * init). `scrollToIndex` takes an options object — ReportActionItemMessageEdit's Android Chrome + * keyboard hack passes `{animated: false}` to scroll instantly while the composer is focused. + */ + +// Returns the ref to register plus a typed handle to the spies for assertions. The spy object +// implements only the methods the scroll handlers invoke. +function buildMockListRef() { + const methods = { + scrollToIndex: jest.fn(), + scrollToOffset: jest.fn(), + scrollToEnd: jest.fn(), + getNativeScrollRef: jest.fn(() => undefined), + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const ref = {current: methods} as unknown as FlatListRefType; + return {ref, methods}; +} + +// Context value backed by a closure holder so registering a ref is visible to the manager's getListRef(). +function buildContextValue() { + let held: FlatListRefType = null; + return { + scrollPositionRef: {current: {}}, + scrollOffsetRef: {current: 0}, + getScrollOffset: () => 0, + registerListRef: (ref: FlatListRefType) => { + held = ref; + }, + getListRef: () => held, + }; +} + +function renderManager() { + const contextValue = buildContextValue(); + function Wrapper({children}: {children: ReactNode}) { + return {children}; + } + return renderHook( + () => { + const manager = useReportScrollManager(); + const {registerListRef} = useActionListContext(); + return {manager, registerListRef}; + }, + {wrapper: Wrapper}, + ); +} + +describe('useReportScrollManager', () => { + it('resolves the list ref at call time, not at init (register AFTER the manager is created)', () => { + const {result} = renderManager(); + const {ref, methods} = buildMockListRef(); + + // Register only after the manager hook has already run once. + act(() => { + result.current.registerListRef(ref); + }); + + act(() => { + result.current.manager.scrollToEnd(); + }); + + // The ref was resolved lazily — the method on the later-registered ref fired. + expect(methods.scrollToEnd).toHaveBeenCalled(); + }); + + it('no-ops when no ref is registered', () => { + const {result} = renderManager(); + + expect(() => { + act(() => { + result.current.manager.scrollToEnd(); + result.current.manager.scrollToIndex(3); + result.current.manager.scrollToOffset(100); + result.current.manager.scrollToIndex(1, {animated: false}); + }); + }).not.toThrow(); + }); + + it('scrollToIndex calls through to the registered ref and defaults to a non-animated (instant) scroll on native', () => { + const {result} = renderManager(); + const {ref, methods} = buildMockListRef(); + act(() => result.current.registerListRef(ref)); + + act(() => result.current.manager.scrollToIndex(5)); + + expect(methods.scrollToIndex).toHaveBeenCalledWith({index: 5, animated: false}); + }); + + it('scrollToOffset calls through to the registered ref', () => { + const {result} = renderManager(); + const {ref, methods} = buildMockListRef(); + act(() => result.current.registerListRef(ref)); + + act(() => result.current.manager.scrollToOffset(250)); + + expect(methods.scrollToOffset).toHaveBeenCalledWith(expect.objectContaining({offset: 250})); + }); + + it('scrollToIndex forwards the animated option to the registered ref (Android Chrome keyboard hack)', () => { + const {result} = renderManager(); + const {ref, methods} = buildMockListRef(); + act(() => result.current.registerListRef(ref)); + + act(() => result.current.manager.scrollToIndex(2, {animated: false})); + + expect(methods.scrollToIndex).toHaveBeenCalledWith({index: 2, animated: false}); + }); +});