diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index bea043be3509..2ffb9f966b12 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,5 +1,6 @@ /* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; +import stableReportSelector from '@selectors/stableReportSelector'; 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'; @@ -51,6 +52,7 @@ import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; +import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; @@ -99,6 +101,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) // Self-subscribe to report, policy, metadata, actions, transactions // report is guaranteed to exist — callers only render this component when report is loaded const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`) as unknown as [OnyxTypes.Report]; + const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {selector: stableReportSelector}); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); const [reportPaginationState] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE}${reportIDFromRoute}`); @@ -559,34 +562,35 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && hasNextActionMadeBySameActor(visibleReportActions, index, isOffline); - const originalReportID = getOriginalReportID(report?.reportID, reportAction, reportActionsObject); + const originalReportID = getOriginalReportID(reportStable?.reportID, reportAction, reportActionsObject); return ( - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - shouldHideThreadDividerLine - linkedReportActionID={linkedReportActionID} - personalDetails={personalDetails} - originalReportID={originalReportID} - isReportArchived={isReportArchived} - isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} - /> + + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + shouldHideThreadDividerLine + linkedReportActionID={linkedReportActionID} + personalDetails={personalDetails} + originalReportID={originalReportID} + isReportArchived={isReportArchived} + isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} + /> + ); }, [ visibleReportActions, reportActionsObject, parentReportAction, - report, + reportStable, isOffline, transactionThreadReport, unreadMarkerReportActionID, diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index c1749cd8654f..fc3c79456e69 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -1,3 +1,4 @@ +import stableReportSelector from '@selectors/stableReportSelector'; import React from 'react'; import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; @@ -28,7 +29,7 @@ function ChatListItem({ personalDetails, }: ChatListItemProps) { const reportActionItem = item as unknown as ReportActionListItemType; - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.reportID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.reportID}`, {selector: stableReportSelector}); const styles = useThemeStyles(); const theme = useTheme(); const animatedHighlightStyle = useAnimatedHighlightStyle({ @@ -79,7 +80,6 @@ function ChatListItem({ parentReportAction={undefined} displayAsGroup={false} shouldDisplayNewMarker={false} - index={item.index ?? 0} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} shouldShowDraftMessage={false} diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 40c29b3a6a5b..e89fe551af14 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -112,7 +112,6 @@ function DebugReportActionCreatePage({ parentReportAction={undefined} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} personalDetails={personalDetailsList} diff --git a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx index f9b3213f477c..1ecbc810afe7 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPreview.tsx @@ -27,7 +27,6 @@ function DebugReportActionPreview({reportAction, reportID}: DebugReportActionPre parentReportAction={undefined} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} personalDetails={personalDetails} diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 474e0749d59d..8b23f9859f35 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -7,11 +7,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getOriginalReportID} from '@libs/ReportUtils'; +import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import {ReportActionItemActionsContext, ReportActionItemStateContext} from '@pages/inbox/report/ReportActionItemContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; +import stableReportSelector from '@src/selectors/stableReportSelector'; type DuplicateTransactionItemProps = { transaction: OnyxEntry; @@ -25,7 +27,7 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic const styles = useThemeStyles(); const personalDetails = usePersonalDetails(); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`, {selector: stableReportSelector}); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`); const action = Object.values(reportActions ?? {})?.find((reportAction) => { @@ -58,19 +60,20 @@ function DuplicateTransactionItem({transaction, index, onPreviewPressed}: Duplic - + + + diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index a613ae9ebc9a..9ab95baece58 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -101,9 +101,6 @@ type PureReportActionItemProps = { /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: boolean; - /** Position index of the report action in the overall report FlatList view */ - index: number; - /** Flag to show, hide the thread divider line */ shouldHideThreadDividerLine?: boolean; @@ -168,7 +165,6 @@ function PureReportActionItem({ transactionThreadReport, linkedReportActionID, displayAsGroup, - index, parentReportAction, shouldDisplayNewMarker, shouldHideThreadDividerLine = false, @@ -608,7 +604,6 @@ function PureReportActionItem({ isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReport} shouldShowBorder={shouldShowBorder} isOnSearch={isOnSearch} - index={index} setIsPaymentMethodPopoverActive={setIsPaymentMethodPopoverActive} /> {Permissions.canUseLinkPreviews() && !isHidden && (action.linkMetadata?.length ?? 0) > 0 && ( @@ -679,7 +674,6 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => { prevProps.report?.description === nextProps.report?.description && isCompletedTaskReport(prevProps.report) === isCompletedTaskReport(nextProps.report) && prevProps.report?.managerID === nextProps.report?.managerID && - prevProps.index === nextProps.index && prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && prevProps.report?.total === nextProps.report?.total && prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx new file mode 100644 index 000000000000..1abe9823fcf3 --- /dev/null +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -0,0 +1,13 @@ +import {createContext} from 'react'; + +/** + * Carries an action item's position index from the list renderer down to the rare consumers that + * actually need it (e.g. `ReportActionItemMessageEdit` for scroll-to-index during edit mode). + * + * Using context keeps `index` out of the prop signatures of every intermediate component, so a + * position shift caused by a new message arriving doesn't cascade re-renders through items that + * never read it. Only components that `useContext(ReportActionIndexContext)` re-render on change. + */ +const ReportActionIndexContext = createContext(0); + +export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 78464a4fd6e4..0ba6de1a3c6f 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -1,3 +1,4 @@ +import stableReportSelector from '@selectors/stableReportSelector'; import React, {useCallback} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; @@ -26,7 +27,7 @@ function ReportActionItem({action, report, draftMessage, personalDetails, linked const reportID = report?.reportID; const originalReportID = useOriginalReportID(reportID, action); const isOriginalReportArchived = useReportIsArchived(originalReportID); - const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); + const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`, {selector: stableReportSelector}); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getIOUReportIDFromReportActionPreview(action)}`); const transactionsOnIOUReport = useReportTransactions(iouReport?.reportID); diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 3d358bde1264..1d7e0e1556ef 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -1,6 +1,6 @@ import lodashDebounce from 'lodash/debounce'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {InteractionManager, View} from 'react-native'; import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInput, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; @@ -59,6 +59,7 @@ import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import Suggestions from './ReportActionCompose/Suggestions'; +import ReportActionIndexContext from './ReportActionIndexContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; type ReportActionItemMessageEditProps = { @@ -77,9 +78,6 @@ type ReportActionItemMessageEditProps = { /** PolicyID of the policy the report belongs to */ policyID?: string; - /** Position index of the report action in the overall report FlatList view */ - index: number; - /** Whether or not the emoji picker is disabled */ shouldDisableEmojiPicker?: boolean; @@ -106,11 +104,11 @@ function ReportActionItemMessageEdit({ reportID, originalReportID, policyID, - index, isGroupPolicyReport, shouldDisableEmojiPicker = false, ref, }: ReportActionItemMessageEditProps) { + const index = useContext(ReportActionIndexContext); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const {email} = useCurrentUserPersonalDetails(); const theme = useTheme(); diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index cbebb4793c98..69de9acb9933 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -35,9 +35,6 @@ type ReportActionItemParentActionProps = { /** Flag to show, hide the thread divider line */ shouldHideThreadDividerLine?: boolean; - /** Position index of the report parent action in the overall report FlatList view */ - index: number; - /** The id of the report */ reportID: string; @@ -72,7 +69,6 @@ function ReportActionItemParentAction({ action, transactionThreadReport, parentReportAction, - index = 0, shouldHideThreadDividerLine = false, shouldDisplayReplyDivider, isFirstVisibleReportAction = false, @@ -214,7 +210,6 @@ function ReportActionItemParentAction({ action={ancestorReportAction} displayAsGroup={false} shouldDisplayNewMarker={ancestor.shouldDisplayNewMarker} - index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} isThreadReportParentAction diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index da669d787e9e..f3ae12c1a374 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,4 +1,5 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; +import stableReportSelector from '@selectors/stableReportSelector'; import type {ListRenderItemInfo} from '@shopify/flash-list'; import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; @@ -74,6 +75,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import FloatingMessageCounter from './FloatingMessageCounter'; import getInitialNumToRender from './getInitialNumReportActionsToRender'; import getReportActionsListInitialNumToRender from './getReportActionsListInitialNumToRender'; +import ReportActionIndexContext from './ReportActionIndexContext'; import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from './shouldDisplayNewMarkerOnReportAction'; @@ -218,6 +220,8 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); + const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, {selector: stableReportSelector}); + const backTo = route?.params?.backTo as string; const linkedReportActionID = route?.params?.reportActionID; @@ -344,6 +348,7 @@ function ReportActionsList({ visibleReportActionsWithDraft.push(draftReportAction); return visibleReportActionsWithDraft; }, [draftReportAction, sortedVisibleReportActions]); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; @@ -770,7 +775,7 @@ function ReportActionsList({ const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => { - const originalReportID = getOriginalReportID(report.reportID, reportAction, reportActionsFromOnyx); + const originalReportID = getOriginalReportID(reportStable?.reportID, reportAction, reportActionsFromOnyx); // 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 @@ -778,13 +783,12 @@ function ReportActionsList({ const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index; return ( - <> + - - + {!!reportStable?.reportID && ( + + )} + ); }, [ parentReportAction, parentReportActionForTransactionThread, - report, + reportStable, isOffline, transactionThreadReport, linkedReportActionID, diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index c1b0870ed31e..38b6ccab3053 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -19,9 +19,6 @@ type ReportActionsListItemRendererProps = { /** The transaction thread report's parentReportAction */ parentReportActionForTransactionThread: OnyxEntry; - /** Position index of the report action in the overall report FlatList view */ - index: number; - /** Report for this action */ report: OnyxEntry; @@ -68,7 +65,6 @@ type ReportActionsListItemRendererProps = { function ReportActionsListItemRenderer({ reportAction, parentReportAction, - index, report, transactionThreadReport, displayAsGroup, @@ -85,11 +81,11 @@ function ReportActionsListItemRenderer({ isReportArchived = false, isHarvestCreatedExpenseReport = false, }: ReportActionsListItemRendererProps) { - const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); - const [reportDraftMessages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`); const draftMessage = reportDraftMessages?.[reportAction.reportActionID]?.message; + const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); + /** * Create a lightweight ReportAction so as to keep the re-rendering as light as possible by * passing in only the required props. @@ -173,7 +169,6 @@ function ReportActionsListItemRenderer({ report={report} action={action} transactionThreadReport={transactionThreadReport} - index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} personalDetails={personalDetails} @@ -193,7 +188,6 @@ function ReportActionsListItemRenderer({ linkedReportActionID={linkedReportActionID} displayAsGroup={displayAsGroup} shouldDisplayNewMarker={shouldDisplayNewMarker} - index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} shouldHighlight={shouldHighlight} diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 27182db38f59..6d333a8aaea1 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -119,9 +119,6 @@ type ActionContentRouterProps = { /** Whether the search-page UI is active */ isOnSearch: boolean; - /** Position index of the report action in the overall report FlatList view */ - index: number; - /** Toggle whether the payment method popover is active */ setIsPaymentMethodPopoverActive: (value: boolean) => void; }; @@ -145,7 +142,6 @@ function ActionContentRouter({ isHarvestCreatedExpenseReport, shouldShowBorder, isOnSearch, - index, setIsPaymentMethodPopoverActive, }: ActionContentRouterProps): React.JSX.Element | null { const {translate, formatTravelDate} = useLocalize(); @@ -386,7 +382,7 @@ function ActionContentRouter({ ); @@ -396,7 +392,7 @@ function ActionContentRouter({ ); @@ -483,7 +479,6 @@ function ActionContentRouter({ originalReportID={originalReportID} displayAsGroup={displayAsGroup} draftMessage={draftMessage} - index={index} isHidden={isHidden} updateHiddenState={updateHiddenState} isArchivedRoom={isArchivedRoom} diff --git a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx index 3a9ac27fc13b..368398b22408 100644 --- a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx @@ -34,7 +34,6 @@ type ChatMessageContentProps = { originalReportID: string; displayAsGroup: boolean; draftMessage: string | undefined; - index: number; isHidden: boolean; updateHiddenState: (isHiddenValue: boolean) => void; isArchivedRoom?: boolean; @@ -50,7 +49,6 @@ function ChatMessageContent({ originalReportID, displayAsGroup, draftMessage, - index, isHidden, updateHiddenState, isArchivedRoom, @@ -117,7 +115,6 @@ function ChatMessageContent({ reportID={reportID} originalReportID={originalReportID} policyID={report?.policyID} - index={index} shouldDisableEmojiPicker={(chatIncludesConcierge(report) && isBlockedFromConcierge(blockedFromConcierge)) || isArchivedNonExpenseReport(report, isArchivedRoom)} isGroupPolicyReport={!!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE} /> diff --git a/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx index df5d455563ac..4aaab8440e57 100644 --- a/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx +++ b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx @@ -1,26 +1,31 @@ import React from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {resolveActionableMentionConfirmWhisper} from '@libs/actions/Report'; import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; import CONST from '@src/CONST'; -import type {Report, ReportAction} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; type ConfirmWhisperContentProps = { action: ReportAction; reportID: string | undefined; originalReportID: string | undefined; - actionReport: OnyxEntry; + actionReportID: string | undefined; }; -function ConfirmWhisperContent({action, reportID, originalReportID, actionReport}: ConfirmWhisperContentProps) { +function ConfirmWhisperContent({action, reportID, originalReportID, actionReportID}: ConfirmWhisperContentProps) { const isOriginalReportArchived = useReportIsArchived(originalReportID); const mentionReportContextValue = {currentReportID: reportID, exactlyMatch: true}; + // Subscribe to the full report here — the resolve action needs heartbeat fields for its + // failure-revert payload that the stable projection strips. + const [actionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${actionReportID}`); + const buttons: ActionableItem[] = [ { text: 'common.buttonConfirm', diff --git a/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx index 9e9e2a92ca7e..90f1ff8ec9bf 100644 --- a/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx +++ b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx @@ -28,10 +28,14 @@ function MentionWhisperContent({action, report, originalReport, originalReportID const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const actionReport = originalReport ?? report; + const actionReportStable = originalReport ?? report; const reportPolicyID = report?.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${reportPolicyID}`); + // `actionReportStable` is a stable projection (heartbeat fields stripped). The resolve action reads + // those fields for its failure-revert payload, so subscribe to the full report here. + const [actionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${actionReportStable?.reportID}`); + const isReportInPolicy = !!reportPolicyID && reportPolicyID !== CONST.POLICY.ID_FAKE && personalPolicyID !== reportPolicyID; const hasMentionedPolicyMembers = getOriginalMessage(action)?.inviteeEmails?.every((login) => isPolicyMember(policy, login)); diff --git a/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx b/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx index 2bde90d6cb47..d23413f1143c 100644 --- a/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx +++ b/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx @@ -1,26 +1,31 @@ import React from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useOnyx from '@hooks/useOnyx'; import {getOriginalMessage} from '@libs/ReportActionsUtils'; import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; import {resolveActionableReportMentionWhisper} from '@userActions/Report'; import CONST from '@src/CONST'; -import type {Report, ReportAction} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; type ReportMentionWhisperContentProps = { action: ReportAction; reportID: string | undefined; - actionReport: OnyxEntry; + actionReportID: string | undefined; isReportArchived: boolean; }; -function ReportMentionWhisperContent({action, reportID, actionReport, isReportArchived}: ReportMentionWhisperContentProps) { +function ReportMentionWhisperContent({action, reportID, actionReportID, isReportArchived}: ReportMentionWhisperContentProps) { const resolution = getOriginalMessage(action)?.resolution; const mentionReportContextValue = {currentReportID: reportID, exactlyMatch: true}; + // Subscribe to the full report here — the resolve action needs heartbeat fields (`lastMessageText`, + // `lastVisibleActionCreated`, `lastActorAccountID`) for its failure-revert payload. + const [actionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${actionReportID}`); + const buttons: ActionableItem[] = resolution ? [] : [ diff --git a/src/selectors/stableReportSelector.ts b/src/selectors/stableReportSelector.ts new file mode 100644 index 000000000000..05308e848539 --- /dev/null +++ b/src/selectors/stableReportSelector.ts @@ -0,0 +1,104 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; +import type {Report} from '@src/types/onyx'; + +type ValidReportKeys> = T; + +/** + * Fields deliberately stripped from the projection. They update on routine activity + * (incoming/outgoing messages, read receipts) and would invalidate the projection on every + * chat heartbeat even though no item-subtree consumer reads them. + */ +type ExcludedFields = ValidReportKeys< + [ + 'lastMessageText', + 'lastVisibleActionCreated', + 'lastReadTime', + 'lastReadSequenceNumber', + 'lastMentionedTime', + 'lastVisibleActionLastModified', + 'lastMessageHtml', + 'lastActorAccountID', + 'lastActionType', + ] +>; + +type StableReport = Omit>; + +/** + * Stable `Report` projection for components that must not re-render on chat heartbeat + * fields (`last*` on `Report`). Intended as a bridge until rows subscribe to derived per-row facts. + * + * If a consumer needs excluded fields (e.g. ConfirmWhisperContent), subscribe separately to the + * full report — do not add those fields back into this projection. + * + * When adding a new `Report` field: include it in the return object below; only add to + * `ExcludedFields` if it updates on every message/read and the subtree does not read it. + */ +function stableReportSelector(report: OnyxEntry) { + if (!report?.reportID) { + return undefined; + } + return { + reportID: report.reportID, + avatarUrl: report.avatarUrl, + created: report.created, + submitted: report.submitted, + approved: report.approved, + chatType: report.chatType, + hasOutstandingChildRequest: report.hasOutstandingChildRequest, + hasOutstandingChildTask: report.hasOutstandingChildTask, + isOwnPolicyExpenseChat: report.isOwnPolicyExpenseChat, + isPinned: report.isPinned, + policyAvatar: report.policyAvatar, + policyName: report.policyName, + oldPolicyName: report.oldPolicyName, + hasParentAccess: report.hasParentAccess, + description: report.description, + isDeletedParentAction: report.isDeletedParentAction, + policyID: report.policyID, + reportName: report.reportName, + chatReportID: report.chatReportID, + stateNum: report.stateNum, + statusNum: report.statusNum, + writeCapability: report.writeCapability, + type: report.type, + visibility: report.visibility, + invoiceReceiver: report.invoiceReceiver, + transactionCount: report.transactionCount, + parentReportID: report.parentReportID, + parentReportActionID: report.parentReportActionID, + // Coerce sentinel `0` to `undefined`. The backend ships `managerID: 0` on chat reports + // without a manager, and a later push removes the key entirely; treating both as + // `undefined` keeps the projection stable through that reconciliation. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + managerID: report.managerID || undefined, + ownerAccountID: report.ownerAccountID, + participants: report.participants, + total: report.total, + unheldTotal: report.unheldTotal, + unheldNonReimbursableTotal: report.unheldNonReimbursableTotal, + currency: report.currency, + errorFields: report.errorFields, + errors: report.errors, + isWaitingOnBankAccount: report.isWaitingOnBankAccount, + isCancelledIOU: report.isCancelledIOU, + hasReportBeenRetracted: report.hasReportBeenRetracted, + hasReportBeenReopened: report.hasReportBeenReopened, + isExportedToIntegration: report.isExportedToIntegration, + hasExportError: report.hasExportError, + iouReportID: report.iouReportID, + preexistingReportID: report.preexistingReportID, + nonReimbursableTotal: report.nonReimbursableTotal, + privateNotes: report.privateNotes, + fieldList: report.fieldList, + permissions: report.permissions, + tripData: report.tripData, + welcomeMessage: report.welcomeMessage, + nextStep: report.nextStep, + pendingAction: report.pendingAction, + pendingFields: report.pendingFields, + } satisfies Record & StableReport; +} + +export default stableReportSelector; diff --git a/tests/ui/ClearReportActionErrorsUITest.tsx b/tests/ui/ClearReportActionErrorsUITest.tsx index e46b322e68db..d7f456a1765c 100644 --- a/tests/ui/ClearReportActionErrorsUITest.tsx +++ b/tests/ui/ClearReportActionErrorsUITest.tsx @@ -97,7 +97,6 @@ describe('ClearReportActionErrors UI', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} originalReportID={originalReportID} /> diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index 7447e968c7c6..bf1b5a67f618 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -107,7 +107,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -393,7 +392,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -441,7 +439,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -493,7 +490,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -537,7 +533,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -606,7 +601,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -662,7 +656,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -745,7 +738,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -924,7 +916,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -971,7 +962,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1125,7 +1115,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1304,7 +1293,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1395,7 +1383,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} isClosedExpenseReportWithNoExpenses /> @@ -1431,7 +1418,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1468,7 +1454,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1516,7 +1501,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1552,7 +1536,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1592,7 +1575,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1630,7 +1612,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1667,7 +1648,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1707,7 +1687,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1748,7 +1727,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1873,7 +1851,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -1910,7 +1887,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -2489,7 +2465,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} /> @@ -2577,7 +2552,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} isHarvestCreatedExpenseReport /> @@ -2624,7 +2598,6 @@ describe('PureReportActionItem', () => { action={action} displayAsGroup={false} shouldDisplayNewMarker={false} - index={0} isFirstVisibleReportAction={false} isThreadReportParentAction /> diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 5587f9027f79..e291591aef49 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -54,7 +54,6 @@ const defaultProps: ReportActionItemMessageEditProps = { draftMessage: '', reportID: defaultReport.reportID, originalReportID: defaultReport.reportID, - index: 0, isGroupPolicyReport: false, }; diff --git a/tests/unit/WhisperContentMentionContextTest.tsx b/tests/unit/WhisperContentMentionContextTest.tsx index 170d8cffaf1b..7a608c10afe5 100644 --- a/tests/unit/WhisperContentMentionContextTest.tsx +++ b/tests/unit/WhisperContentMentionContextTest.tsx @@ -54,11 +54,6 @@ function createWhisperAction(actionName: T) { } as ReportAction; } -const report = { - reportID: REPORT_ID, - policyID: POLICY_ID, -} as Report; - describe('Whisper content components provide MentionReportContext so room mentions render as links', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -95,7 +90,7 @@ describe('Whisper content components provide MentionReportContext so room mentio } reportID={REPORT_ID} - actionReport={report} + actionReportID={REPORT_ID} isReportArchived={false} /> , @@ -115,7 +110,7 @@ describe('Whisper content components provide MentionReportContext so room mentio action={action as ReportAction} reportID={REPORT_ID} originalReportID={undefined} - actionReport={report} + actionReportID={REPORT_ID} /> , );