From 871a001ae1a3475843a08b605e2fe48bd4987702 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 14 May 2026 13:57:15 +0200 Subject: [PATCH 1/8] add index context and stable report --- .../MoneyRequestReportActionsList.tsx | 44 +++-- .../SearchList/ListItem/ChatListItem.tsx | 1 - ...seStableReportActionForReportActionItem.ts | 109 ++++++++++ .../useStableReportForReportActionItem.ts | 139 +++++++++++++ .../DebugReportActionCreatePage.tsx | 1 - .../ReportAction/DebugReportActionPreview.tsx | 1 - .../DuplicateTransactionItem.tsx | 28 +-- .../inbox/report/PureReportActionItem.tsx | 6 - .../inbox/report/ReportActionIndexContext.tsx | 13 ++ .../report/ReportActionItemMessageEdit.tsx | 8 +- .../report/ReportActionItemParentAction.tsx | 5 - src/pages/inbox/report/ReportActionsList.tsx | 17 +- .../report/ReportActionsListItemRenderer.tsx | 87 +------- .../actionContents/ActionContentRouter.tsx | 5 - .../actionContents/ChatMessageContent.tsx | 3 - tests/ui/ClearReportActionErrorsUITest.tsx | 1 - tests/ui/PureReportActionItemTest.tsx | 27 --- tests/ui/ReportActionItemMessageEditTest.tsx | 1 - ...ableReportActionForReportActionItemTest.ts | 143 ++++++++++++++ .../useStableReportForReportActionItemTest.ts | 186 ++++++++++++++++++ 20 files changed, 648 insertions(+), 177 deletions(-) create mode 100644 src/hooks/useStableReportActionForReportActionItem.ts create mode 100644 src/hooks/useStableReportForReportActionItem.ts create mode 100644 src/pages/inbox/report/ReportActionIndexContext.tsx create mode 100644 tests/unit/useStableReportActionForReportActionItemTest.ts create mode 100644 tests/unit/useStableReportForReportActionItemTest.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index bea043be3509..bbfa597fd14f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -22,6 +22,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; +import useStableReportForReportActionItem from '@hooks/useStableReportForReportActionItem'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; @@ -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 = useStableReportForReportActionItem(report); 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..62d85bce8fa0 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -79,7 +79,6 @@ function ChatListItem({ parentReportAction={undefined} displayAsGroup={false} shouldDisplayNewMarker={false} - index={item.index ?? 0} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} shouldShowDraftMessage={false} diff --git a/src/hooks/useStableReportActionForReportActionItem.ts b/src/hooks/useStableReportActionForReportActionItem.ts new file mode 100644 index 000000000000..2db7fa7c76da --- /dev/null +++ b/src/hooks/useStableReportActionForReportActionItem.ts @@ -0,0 +1,109 @@ +import {useMemo} from 'react'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import type {ReportAction} from '@src/types/onyx'; + +/** + * Stable `ReportAction` projection for the `ReportActionItem` subtree, with `originalMessage` precomputed. + * Per-field `useMemo` deps so the ref holds when consumed fields are unchanged. + * Allow-list — when a descendant reads a new `reportAction.*` field, add it here. + */ +function useStableReportActionForReportActionItem(reportAction: ReportAction): ReportAction { + const reportActionID = reportAction.reportActionID; + const message = reportAction.message; + const pendingAction = reportAction.pendingAction; + const actionName = reportAction.actionName; + const errors = reportAction.errors; + const childCommenterCount = reportAction.childCommenterCount; + const linkMetadata = reportAction.linkMetadata; + const childReportID = reportAction.childReportID; + const childLastVisibleActionCreated = reportAction.childLastVisibleActionCreated; + const error = reportAction.error; + const created = reportAction.created; + const actorAccountID = reportAction.actorAccountID; + const adminAccountID = reportAction.adminAccountID; + const childVisibleActionCount = reportAction.childVisibleActionCount; + const childOldestFourAccountIDs = reportAction.childOldestFourAccountIDs; + const childType = reportAction.childType; + const person = reportAction.person; + const isOptimisticAction = reportAction.isOptimisticAction; + const delegateAccountID = reportAction.delegateAccountID; + const previousMessage = reportAction.previousMessage; + const isAttachmentWithText = reportAction.isAttachmentWithText; + const isOriginalReportDeleted = reportAction.isOriginalReportDeleted; + const childStateNum = reportAction.childStateNum; + const childStatusNum = reportAction.childStatusNum; + const childReportName = reportAction.childReportName; + const childManagerAccountID = reportAction.childManagerAccountID; + const childMoneyRequestCount = reportAction.childMoneyRequestCount; + const childOwnerAccountID = reportAction.childOwnerAccountID; + + const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); + + return useMemo( + () => + ({ + reportActionID, + message, + pendingAction, + actionName, + errors, + originalMessage, + childCommenterCount, + linkMetadata, + childReportID, + childLastVisibleActionCreated, + error, + created, + actorAccountID, + adminAccountID, + childVisibleActionCount, + childOldestFourAccountIDs, + childType, + person, + isOptimisticAction, + delegateAccountID, + previousMessage, + isAttachmentWithText, + isOriginalReportDeleted, + childStateNum, + childStatusNum, + childReportName, + childManagerAccountID, + childMoneyRequestCount, + childOwnerAccountID, + }) as ReportAction, + [ + reportActionID, + message, + pendingAction, + actionName, + errors, + originalMessage, + childCommenterCount, + linkMetadata, + childReportID, + childLastVisibleActionCreated, + error, + created, + actorAccountID, + adminAccountID, + childVisibleActionCount, + childOldestFourAccountIDs, + childType, + person, + isOptimisticAction, + delegateAccountID, + previousMessage, + isAttachmentWithText, + isOriginalReportDeleted, + childStateNum, + childStatusNum, + childReportName, + childManagerAccountID, + childMoneyRequestCount, + childOwnerAccountID, + ], + ); +} + +export default useStableReportActionForReportActionItem; diff --git a/src/hooks/useStableReportForReportActionItem.ts b/src/hooks/useStableReportForReportActionItem.ts new file mode 100644 index 000000000000..8a7f0c1efeae --- /dev/null +++ b/src/hooks/useStableReportForReportActionItem.ts @@ -0,0 +1,139 @@ +import {deepEqual} from 'fast-equals'; +import {useMemo, useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Report} from '@src/types/onyx'; + +/** + * Stable `Report` projection for the `ReportActionItem` subtree. + * Per-field `useMemo` deps so the ref holds across Onyx pushes when consumed fields are unchanged. + * + * Never add — heartbeat-style fields that update on routine activity and defeat the memo: + * - lastReadTime, lastVisibleActionCreated, lastVisibleActionLastModified + * - lastMessageText, lastMessageHtml + * - lastActorAccountID, lastActionType + * + */ +function useStableReportForReportActionItem(report: OnyxEntry): OnyxEntry { + const reportID = report?.reportID; + const chatReportID = report?.chatReportID; + const isWaitingOnBankAccount = report?.isWaitingOnBankAccount; + + // Stabilize `permissions` by content + const permissionsRaw = report?.permissions; + const [permissions, setPermissions] = useState(permissionsRaw); + if (!deepEqual(permissions, permissionsRaw)) { + setPermissions(permissionsRaw); + } + + const policyID = report?.policyID; + const ownerAccountID = report?.ownerAccountID; + const parentReportID = report?.parentReportID; + const parentReportActionID = report?.parentReportActionID; + const type = report?.type; + const chatType = report?.chatType; + const stateNum = report?.stateNum; + const statusNum = report?.statusNum; + const isDeletedParentAction = report?.isDeletedParentAction; + const pendingFields = report?.pendingFields; + const participants = report?.participants; + const errorFields = report?.errorFields; + const reportName = report?.reportName; + const description = report?.description; + // 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 memo dep stable through that reconciliation. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const managerID = report?.managerID || undefined; + const total = report?.total; + const nonReimbursableTotal = report?.nonReimbursableTotal; + const policyAvatar = report?.policyAvatar; + const fieldList = report?.fieldList; + const iouReportID = report?.iouReportID; + const isCancelledIOU = report?.isCancelledIOU; + const isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat; + const writeCapability = report?.writeCapability; + const currency = report?.currency; + const visibility = report?.visibility; + const avatarUrl = report?.avatarUrl; + const policyName = report?.policyName; + const transactionCount = report?.transactionCount; + const unheldTotal = report?.unheldTotal; + const created = report?.created; + + return useMemo(() => { + if (reportID === undefined) { + return undefined; + } + return { + reportID, + chatReportID, + isWaitingOnBankAccount, + permissions, + policyID, + ownerAccountID, + parentReportID, + parentReportActionID, + type, + chatType, + stateNum, + statusNum, + isDeletedParentAction, + pendingFields, + participants, + errorFields, + reportName, + description, + managerID, + total, + nonReimbursableTotal, + policyAvatar, + fieldList, + iouReportID, + isCancelledIOU, + isOwnPolicyExpenseChat, + writeCapability, + currency, + visibility, + avatarUrl, + policyName, + transactionCount, + unheldTotal, + created, + } as Report; + }, [ + reportID, + chatReportID, + isWaitingOnBankAccount, + permissions, + policyID, + ownerAccountID, + parentReportID, + parentReportActionID, + type, + chatType, + stateNum, + statusNum, + isDeletedParentAction, + pendingFields, + participants, + errorFields, + reportName, + description, + managerID, + total, + nonReimbursableTotal, + policyAvatar, + fieldList, + iouReportID, + isCancelledIOU, + isOwnPolicyExpenseChat, + writeCapability, + currency, + visibility, + avatarUrl, + policyName, + transactionCount, + unheldTotal, + created, + ]); +} + +export default useStableReportForReportActionItem; 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..64a36359ed3b 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -7,6 +7,7 @@ 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'; @@ -58,19 +59,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/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..5b007dee72c2 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -20,6 +20,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; +import useStableReportForReportActionItem from '@hooks/useStableReportForReportActionItem'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -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 = useStableReportForReportActionItem(report) ?? report; + const backTo = route?.params?.backTo as string; const linkedReportActionID = route?.params?.reportActionID; @@ -770,7 +774,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 +782,12 @@ function ReportActionsList({ const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index; return ( - <> + - + ); }, [ 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..a6db1fb18a4a 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -1,7 +1,8 @@ -import React, {memo, useMemo} from 'react'; +import React, {memo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; -import {getOriginalMessage, isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; +import useStableReportActionForReportActionItem from '@hooks/useStableReportActionForReportActionItem'; +import {isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; import {isChatThread} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -19,9 +20,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 +66,6 @@ type ReportActionsListItemRendererProps = { function ReportActionsListItemRenderer({ reportAction, parentReportAction, - index, report, transactionThreadReport, displayAsGroup, @@ -85,80 +82,10 @@ 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; - /** - * Create a lightweight ReportAction so as to keep the re-rendering as light as possible by - * passing in only the required props. - */ - const action: ReportAction = useMemo( - () => - ({ - reportActionID: reportAction.reportActionID, - message: reportAction.message, - pendingAction: reportAction.pendingAction, - actionName: reportAction.actionName, - errors: reportAction.errors, - originalMessage, - childCommenterCount: reportAction.childCommenterCount, - linkMetadata: reportAction.linkMetadata, - childReportID: reportAction.childReportID, - childLastVisibleActionCreated: reportAction.childLastVisibleActionCreated, - error: reportAction.error, - created: reportAction.created, - actorAccountID: reportAction.actorAccountID, - adminAccountID: reportAction.adminAccountID, - childVisibleActionCount: reportAction.childVisibleActionCount, - childOldestFourAccountIDs: reportAction.childOldestFourAccountIDs, - childType: reportAction.childType, - person: reportAction.person, - isOptimisticAction: reportAction.isOptimisticAction, - delegateAccountID: reportAction.delegateAccountID, - previousMessage: reportAction.previousMessage, - isAttachmentWithText: reportAction.isAttachmentWithText, - isOriginalReportDeleted: reportAction.isOriginalReportDeleted, - childStateNum: reportAction.childStateNum, - childStatusNum: reportAction.childStatusNum, - childReportName: reportAction.childReportName, - childManagerAccountID: reportAction.childManagerAccountID, - childMoneyRequestCount: reportAction.childMoneyRequestCount, - childOwnerAccountID: reportAction.childOwnerAccountID, - }) as ReportAction, - [ - reportAction.reportActionID, - reportAction.message, - reportAction.pendingAction, - reportAction.actionName, - reportAction.errors, - reportAction.childCommenterCount, - reportAction.linkMetadata, - reportAction.childReportID, - reportAction.childLastVisibleActionCreated, - reportAction.error, - reportAction.created, - reportAction.actorAccountID, - reportAction.adminAccountID, - reportAction.childVisibleActionCount, - reportAction.childOldestFourAccountIDs, - reportAction.childType, - reportAction.person, - reportAction.isOptimisticAction, - reportAction.delegateAccountID, - reportAction.previousMessage, - reportAction.isAttachmentWithText, - reportAction.isOriginalReportDeleted, - reportAction.childStateNum, - reportAction.childStatusNum, - reportAction.childReportName, - reportAction.childManagerAccountID, - reportAction.childMoneyRequestCount, - reportAction.childOwnerAccountID, - originalMessage, - ], - ); + const actionStable = useStableReportActionForReportActionItem(reportAction); const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && (!isTransactionThread(parentReportAction) || isSentMoneyReportAction(parentReportAction)); @@ -171,9 +98,8 @@ function ReportActionsListItemRenderer({ parentReportAction={parentReportAction} reportID={report.reportID} report={report} - action={action} + action={actionStable} transactionThreadReport={transactionThreadReport} - index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} personalDetails={personalDetails} @@ -189,11 +115,10 @@ function ReportActionsListItemRenderer({ report={report} transactionThreadReport={transactionThreadReport} parentReportActionForTransactionThread={parentReportActionForTransactionThread} - action={action} + action={actionStable} 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..0f76b147a249 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(); @@ -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/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/useStableReportActionForReportActionItemTest.ts b/tests/unit/useStableReportActionForReportActionItemTest.ts new file mode 100644 index 000000000000..1254ebbaeb1e --- /dev/null +++ b/tests/unit/useStableReportActionForReportActionItemTest.ts @@ -0,0 +1,143 @@ +import {renderHook} from '@testing-library/react-native'; +import useStableReportActionForReportActionItem from '@hooks/useStableReportActionForReportActionItem'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +function buildAction(overrides: Partial = {}): ReportAction { + return { + reportActionID: 'a-1', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2026-05-13 12:00:00', + actorAccountID: 10, + message: [{type: 'COMMENT', html: 'hello', text: 'hello'}], + originalMessage: {html: 'hello', whisperedTo: []}, + person: [{type: 'TEXT', style: 'strong', text: 'Tester'}], + ...overrides, + } as ReportAction; +} + +const EXPECTED_ACTION_KEYS = [ + 'reportActionID', + 'message', + 'pendingAction', + 'actionName', + 'errors', + 'originalMessage', + 'childCommenterCount', + 'linkMetadata', + 'childReportID', + 'childLastVisibleActionCreated', + 'error', + 'created', + 'actorAccountID', + 'adminAccountID', + 'childVisibleActionCount', + 'childOldestFourAccountIDs', + 'childType', + 'person', + 'isOptimisticAction', + 'delegateAccountID', + 'previousMessage', + 'isAttachmentWithText', + 'isOriginalReportDeleted', + 'childStateNum', + 'childStatusNum', + 'childReportName', + 'childManagerAccountID', + 'childMoneyRequestCount', + 'childOwnerAccountID', +].sort(); + +const ALLOW_LIST_CASES: Array<[string, unknown]> = [ + ['reportActionID', 'a-2'], + ['message', [{type: 'COMMENT', html: 'world', text: 'world'}]], + ['pendingAction', 'add'], + ['actionName', CONST.REPORT.ACTIONS.TYPE.IOU], + ['errors', {foo: 'err'}], + ['originalMessage', {html: 'world', whisperedTo: []}], + ['childCommenterCount', 5], + ['linkMetadata', [{href: 'x'}]], + ['childReportID', 'child-1'], + ['childLastVisibleActionCreated', '2026-05-13 13:00:00'], + ['error', 'oops'], + ['created', '2026-05-13 14:00:00'], + ['actorAccountID', 99], + ['adminAccountID', 100], + ['childVisibleActionCount', 3], + ['childOldestFourAccountIDs', '1,2,3,4'], + ['childType', 'chat'], + ['person', [{type: 'TEXT', style: 'strong', text: 'Other'}]], + ['isOptimisticAction', true], + ['delegateAccountID', 42], + ['previousMessage', [{type: 'COMMENT', html: 'prev', text: 'prev'}]], + ['isAttachmentWithText', true], + ['isOriginalReportDeleted', true], + ['childStateNum', 1], + ['childStatusNum', 1], + ['childReportName', 'child report'], + ['childManagerAccountID', 50], + ['childMoneyRequestCount', 2], + ['childOwnerAccountID', 60], +]; + +// Real ReportAction fields the hook deliberately omits from its projection (no descendant reads them). +// Churn on these must not invalidate the memo. +const DENY_LIST_CASES: Array<[string, unknown]> = [ + ['lastModified', '2026-05-13 12:00:01'], + ['automatic', true], + ['avatar', 'http://avatar'], + ['shouldShow', false], + ['isLoading', true], + ['accountID', 99], + ['whisperedTo', [1, 2]], +]; + +describe('useStableReportActionForReportActionItem', () => { + it('projects only the documented allow-list keys', () => { + const {result} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { + initialProps: {reportAction: buildAction()}, + }); + expect(Object.keys(result.current).sort()).toEqual(EXPECTED_ACTION_KEYS); + }); + + it.each(ALLOW_LIST_CASES)('flips the projection ref when `%s` changes', (field, nextValue) => { + const initial = buildAction(); + const {result, rerender} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { + initialProps: {reportAction: initial}, + }); + const firstRef = result.current; + + rerender({reportAction: {...initial, [field]: nextValue} as ReportAction}); + expect(result.current).not.toBe(firstRef); + }); + + it.each(DENY_LIST_CASES)('holds the projection ref when `%s` churns', (field, nextValue) => { + const initial = buildAction(); + const {result, rerender} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { + initialProps: {reportAction: initial}, + }); + const firstRef = result.current; + + rerender({reportAction: {...initial, [field]: nextValue} as ReportAction}); + expect(result.current).toBe(firstRef); + }); + + it('precomputes originalMessage on the projection', () => { + const {result} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { + initialProps: {reportAction: buildAction()}, + }); + expect(getOriginalMessage(result.current)).toEqual({html: 'hello', whisperedTo: []}); + }); + + it('keeps the projection ref stable when the upstream top-level ref flips but consumed fields are unchanged', () => { + const initial = buildAction(); + const {result, rerender} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { + initialProps: {reportAction: initial}, + }); + const firstRef = result.current; + + rerender({reportAction: {...initial} as ReportAction}); + expect(result.current).toBe(firstRef); + }); +}); diff --git a/tests/unit/useStableReportForReportActionItemTest.ts b/tests/unit/useStableReportForReportActionItemTest.ts new file mode 100644 index 000000000000..215a041453ab --- /dev/null +++ b/tests/unit/useStableReportForReportActionItemTest.ts @@ -0,0 +1,186 @@ +import {renderHook} from '@testing-library/react-native'; +import useStableReportForReportActionItem from '@hooks/useStableReportForReportActionItem'; +import type {Report} from '@src/types/onyx'; + +function buildReport(overrides: Partial = {}): Report { + return { + reportID: 'r-1', + type: 'chat', + chatType: '' as Report['chatType'], + policyID: 'p-1', + ownerAccountID: 10, + parentReportID: undefined, + parentReportActionID: undefined, + stateNum: 0, + statusNum: 0, + participants: {p10: {notificationPreference: 'always'}, p20: {notificationPreference: 'always'}}, + permissions: ['read', 'write'], + reportName: 'Chat', + description: '', + total: 0, + currency: 'USD', + ...overrides, + } as Report; +} + +const EXPECTED_REPORT_KEYS = [ + 'reportID', + 'chatReportID', + 'isWaitingOnBankAccount', + 'permissions', + 'policyID', + 'ownerAccountID', + 'parentReportID', + 'parentReportActionID', + 'type', + 'chatType', + 'stateNum', + 'statusNum', + 'isDeletedParentAction', + 'pendingFields', + 'participants', + 'errorFields', + 'reportName', + 'description', + 'managerID', + 'total', + 'nonReimbursableTotal', + 'policyAvatar', + 'fieldList', + 'iouReportID', + 'isCancelledIOU', + 'isOwnPolicyExpenseChat', + 'writeCapability', + 'currency', + 'visibility', + 'avatarUrl', + 'policyName', + 'transactionCount', + 'unheldTotal', + 'created', +].sort(); + +const ALLOW_LIST_CASES: Array<[string, unknown]> = [ + ['reportID', 'r-2'], + ['chatReportID', 'chat-1'], + ['isWaitingOnBankAccount', true], + ['permissions', ['read']], + ['policyID', 'p-2'], + ['ownerAccountID', 99], + ['parentReportID', 'parent-1'], + ['parentReportActionID', 'pa-1'], + ['type', 'iou'], + ['chatType', 'policyAnnounce'], + ['stateNum', 1], + ['statusNum', 1], + ['isDeletedParentAction', true], + ['pendingFields', {reportName: 'add'}], + ['participants', {p30: {notificationPreference: 'always'}}], + ['errorFields', {currency: {foo: 'err'}}], + ['reportName', 'Different name'], + ['description', 'a desc'], + ['managerID', 42], + ['total', 1000], + ['nonReimbursableTotal', 500], + ['policyAvatar', 'avatar-url'], + ['fieldList', {foo: 'bar'}], + ['iouReportID', 'iou-1'], + ['isCancelledIOU', true], + ['isOwnPolicyExpenseChat', true], + ['writeCapability', 'admins'], + ['currency', 'EUR'], + ['visibility', 'public'], + ['avatarUrl', 'http://avatar'], + ['policyName', 'Different policy'], + ['transactionCount', 5], + ['unheldTotal', 200], + ['created', '2026-05-13 12:00:00'], +]; + +// Mirrors the "Never add" list in useStableReportForReportActionItem.ts: heartbeat-style fields +// that update on routine activity. These must not invalidate the memo or downstream re-renders cascade. +const DENY_LIST_CASES: Array<[string, unknown]> = [ + ['lastReadTime', '2026-05-13 12:00:00'], + ['lastVisibleActionCreated', '2026-05-13 12:00:01'], + ['lastVisibleActionLastModified', '2026-05-13 12:00:02'], + ['lastMessageText', 'msg'], + ['lastMessageHtml', '

msg

'], + ['lastActorAccountID', 99], + ['lastActionType', 'add'], +]; + +describe('useStableReportForReportActionItem', () => { + it('returns undefined when reportID is missing on an otherwise populated blob', () => { + const {result} = renderHook(({report}) => useStableReportForReportActionItem(report), { + initialProps: {report: buildReport({reportID: undefined})}, + }); + expect(result.current).toBeUndefined(); + }); + + it('projects only the documented allow-list keys', () => { + const {result} = renderHook(({report}) => useStableReportForReportActionItem(report), { + initialProps: {report: buildReport()}, + }); + expect(Object.keys(result.current ?? {}).sort()).toEqual(EXPECTED_REPORT_KEYS); + }); + + it.each(ALLOW_LIST_CASES)('flips the projection ref when `%s` changes', (field, nextValue) => { + const initial = buildReport(); + const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { + initialProps: {report: initial}, + }); + const firstRef = result.current; + + rerender({report: {...initial, [field]: nextValue} as Report}); + expect(result.current).not.toBe(firstRef); + }); + + it.each(DENY_LIST_CASES)('holds the projection ref when `%s` churns', (field, nextValue) => { + const initial = buildReport(); + const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { + initialProps: {report: initial}, + }); + const firstRef = result.current; + + rerender({report: {...initial, [field]: nextValue} as Report}); + expect(result.current).toBe(firstRef); + }); + + it('coerces managerID 0 to undefined and keeps the ref stable through the 0 -> undefined reconciliation', () => { + // Backend ships managerID: 0 on chat reports without a manager; a later Onyx merge of + // `{managerID: null}` deletes the key. Both must coerce to undefined for the memo to hold. + const withZero = buildReport({managerID: 0}); + const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { + initialProps: {report: withZero}, + }); + const firstRef = result.current; + expect(firstRef?.managerID).toBeUndefined(); + + rerender({report: {...withZero, managerID: undefined}}); + + expect(result.current).toBe(firstRef); + expect(result.current?.managerID).toBeUndefined(); + }); + + it('keeps the projection ref stable when the upstream top-level ref flips but consumed primitives are unchanged', () => { + const initial = buildReport(); + const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { + initialProps: {report: initial}, + }); + const firstRef = result.current; + + rerender({report: {...initial} as Report}); + expect(result.current).toBe(firstRef); + }); + + it('holds the projection ref when `permissions` ref flips but contents are identical', () => { + const initial = buildReport({permissions: ['read', 'write']}); + const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { + initialProps: {report: initial}, + }); + const firstRef = result.current; + + rerender({report: {...initial, permissions: ['read', 'write']} as Report}); + expect(result.current).toBe(firstRef); + }); +}); From 94cc3e5855c353b2c4a654bbbc70ee5354fac11f Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 14 May 2026 15:37:28 +0200 Subject: [PATCH 2/8] use selectors instead of hooks --- .../MoneyRequestReportActionsList.tsx | 4 +- ...seStableReportActionForReportActionItem.ts | 109 ---------- .../useStableReportForReportActionItem.ts | 139 ------------- src/pages/inbox/report/ReportActionItem.tsx | 3 +- src/pages/inbox/report/ReportActionsList.tsx | 5 +- .../report/ReportActionsListItemRenderer.tsx | 81 +++++++- src/selectors/Report.ts | 86 +++++++- ...ableReportActionForReportActionItemTest.ts | 143 -------------- .../useStableReportForReportActionItemTest.ts | 186 ------------------ 9 files changed, 167 insertions(+), 589 deletions(-) delete mode 100644 src/hooks/useStableReportActionForReportActionItem.ts delete mode 100644 src/hooks/useStableReportForReportActionItem.ts delete mode 100644 tests/unit/useStableReportActionForReportActionItemTest.ts delete mode 100644 tests/unit/useStableReportForReportActionItemTest.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index bbfa597fd14f..71c7ac9f8da8 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/Report'; 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'; @@ -22,7 +23,6 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; -import useStableReportForReportActionItem from '@hooks/useStableReportForReportActionItem'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; @@ -101,7 +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 = useStableReportForReportActionItem(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}`); diff --git a/src/hooks/useStableReportActionForReportActionItem.ts b/src/hooks/useStableReportActionForReportActionItem.ts deleted file mode 100644 index 2db7fa7c76da..000000000000 --- a/src/hooks/useStableReportActionForReportActionItem.ts +++ /dev/null @@ -1,109 +0,0 @@ -import {useMemo} from 'react'; -import {getOriginalMessage} from '@libs/ReportActionsUtils'; -import type {ReportAction} from '@src/types/onyx'; - -/** - * Stable `ReportAction` projection for the `ReportActionItem` subtree, with `originalMessage` precomputed. - * Per-field `useMemo` deps so the ref holds when consumed fields are unchanged. - * Allow-list — when a descendant reads a new `reportAction.*` field, add it here. - */ -function useStableReportActionForReportActionItem(reportAction: ReportAction): ReportAction { - const reportActionID = reportAction.reportActionID; - const message = reportAction.message; - const pendingAction = reportAction.pendingAction; - const actionName = reportAction.actionName; - const errors = reportAction.errors; - const childCommenterCount = reportAction.childCommenterCount; - const linkMetadata = reportAction.linkMetadata; - const childReportID = reportAction.childReportID; - const childLastVisibleActionCreated = reportAction.childLastVisibleActionCreated; - const error = reportAction.error; - const created = reportAction.created; - const actorAccountID = reportAction.actorAccountID; - const adminAccountID = reportAction.adminAccountID; - const childVisibleActionCount = reportAction.childVisibleActionCount; - const childOldestFourAccountIDs = reportAction.childOldestFourAccountIDs; - const childType = reportAction.childType; - const person = reportAction.person; - const isOptimisticAction = reportAction.isOptimisticAction; - const delegateAccountID = reportAction.delegateAccountID; - const previousMessage = reportAction.previousMessage; - const isAttachmentWithText = reportAction.isAttachmentWithText; - const isOriginalReportDeleted = reportAction.isOriginalReportDeleted; - const childStateNum = reportAction.childStateNum; - const childStatusNum = reportAction.childStatusNum; - const childReportName = reportAction.childReportName; - const childManagerAccountID = reportAction.childManagerAccountID; - const childMoneyRequestCount = reportAction.childMoneyRequestCount; - const childOwnerAccountID = reportAction.childOwnerAccountID; - - const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); - - return useMemo( - () => - ({ - reportActionID, - message, - pendingAction, - actionName, - errors, - originalMessage, - childCommenterCount, - linkMetadata, - childReportID, - childLastVisibleActionCreated, - error, - created, - actorAccountID, - adminAccountID, - childVisibleActionCount, - childOldestFourAccountIDs, - childType, - person, - isOptimisticAction, - delegateAccountID, - previousMessage, - isAttachmentWithText, - isOriginalReportDeleted, - childStateNum, - childStatusNum, - childReportName, - childManagerAccountID, - childMoneyRequestCount, - childOwnerAccountID, - }) as ReportAction, - [ - reportActionID, - message, - pendingAction, - actionName, - errors, - originalMessage, - childCommenterCount, - linkMetadata, - childReportID, - childLastVisibleActionCreated, - error, - created, - actorAccountID, - adminAccountID, - childVisibleActionCount, - childOldestFourAccountIDs, - childType, - person, - isOptimisticAction, - delegateAccountID, - previousMessage, - isAttachmentWithText, - isOriginalReportDeleted, - childStateNum, - childStatusNum, - childReportName, - childManagerAccountID, - childMoneyRequestCount, - childOwnerAccountID, - ], - ); -} - -export default useStableReportActionForReportActionItem; diff --git a/src/hooks/useStableReportForReportActionItem.ts b/src/hooks/useStableReportForReportActionItem.ts deleted file mode 100644 index 8a7f0c1efeae..000000000000 --- a/src/hooks/useStableReportForReportActionItem.ts +++ /dev/null @@ -1,139 +0,0 @@ -import {deepEqual} from 'fast-equals'; -import {useMemo, useState} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import type {Report} from '@src/types/onyx'; - -/** - * Stable `Report` projection for the `ReportActionItem` subtree. - * Per-field `useMemo` deps so the ref holds across Onyx pushes when consumed fields are unchanged. - * - * Never add — heartbeat-style fields that update on routine activity and defeat the memo: - * - lastReadTime, lastVisibleActionCreated, lastVisibleActionLastModified - * - lastMessageText, lastMessageHtml - * - lastActorAccountID, lastActionType - * - */ -function useStableReportForReportActionItem(report: OnyxEntry): OnyxEntry { - const reportID = report?.reportID; - const chatReportID = report?.chatReportID; - const isWaitingOnBankAccount = report?.isWaitingOnBankAccount; - - // Stabilize `permissions` by content - const permissionsRaw = report?.permissions; - const [permissions, setPermissions] = useState(permissionsRaw); - if (!deepEqual(permissions, permissionsRaw)) { - setPermissions(permissionsRaw); - } - - const policyID = report?.policyID; - const ownerAccountID = report?.ownerAccountID; - const parentReportID = report?.parentReportID; - const parentReportActionID = report?.parentReportActionID; - const type = report?.type; - const chatType = report?.chatType; - const stateNum = report?.stateNum; - const statusNum = report?.statusNum; - const isDeletedParentAction = report?.isDeletedParentAction; - const pendingFields = report?.pendingFields; - const participants = report?.participants; - const errorFields = report?.errorFields; - const reportName = report?.reportName; - const description = report?.description; - // 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 memo dep stable through that reconciliation. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const managerID = report?.managerID || undefined; - const total = report?.total; - const nonReimbursableTotal = report?.nonReimbursableTotal; - const policyAvatar = report?.policyAvatar; - const fieldList = report?.fieldList; - const iouReportID = report?.iouReportID; - const isCancelledIOU = report?.isCancelledIOU; - const isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat; - const writeCapability = report?.writeCapability; - const currency = report?.currency; - const visibility = report?.visibility; - const avatarUrl = report?.avatarUrl; - const policyName = report?.policyName; - const transactionCount = report?.transactionCount; - const unheldTotal = report?.unheldTotal; - const created = report?.created; - - return useMemo(() => { - if (reportID === undefined) { - return undefined; - } - return { - reportID, - chatReportID, - isWaitingOnBankAccount, - permissions, - policyID, - ownerAccountID, - parentReportID, - parentReportActionID, - type, - chatType, - stateNum, - statusNum, - isDeletedParentAction, - pendingFields, - participants, - errorFields, - reportName, - description, - managerID, - total, - nonReimbursableTotal, - policyAvatar, - fieldList, - iouReportID, - isCancelledIOU, - isOwnPolicyExpenseChat, - writeCapability, - currency, - visibility, - avatarUrl, - policyName, - transactionCount, - unheldTotal, - created, - } as Report; - }, [ - reportID, - chatReportID, - isWaitingOnBankAccount, - permissions, - policyID, - ownerAccountID, - parentReportID, - parentReportActionID, - type, - chatType, - stateNum, - statusNum, - isDeletedParentAction, - pendingFields, - participants, - errorFields, - reportName, - description, - managerID, - total, - nonReimbursableTotal, - policyAvatar, - fieldList, - iouReportID, - isCancelledIOU, - isOwnPolicyExpenseChat, - writeCapability, - currency, - visibility, - avatarUrl, - policyName, - transactionCount, - unheldTotal, - created, - ]); -} - -export default useStableReportForReportActionItem; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 78464a4fd6e4..806bd36911bb 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -1,3 +1,4 @@ +import {stableReportSelector} from '@selectors/Report'; 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/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 5b007dee72c2..69a70bcc96ee 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/Report'; 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'; @@ -20,7 +21,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; -import useStableReportForReportActionItem from '@hooks/useStableReportForReportActionItem'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -220,7 +220,7 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - const reportStable = useStableReportForReportActionItem(report) ?? report; + const [reportStable = report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, {selector: stableReportSelector}); const backTo = route?.params?.backTo as string; const linkedReportActionID = route?.params?.reportActionID; @@ -348,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 ?? ''}` : ''; diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index a6db1fb18a4a..38b6ccab3053 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -1,8 +1,7 @@ -import React, {memo} from 'react'; +import React, {memo, useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; -import useStableReportActionForReportActionItem from '@hooks/useStableReportActionForReportActionItem'; -import {isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; import {isChatThread} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -85,7 +84,77 @@ function ReportActionsListItemRenderer({ const [reportDraftMessages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`); const draftMessage = reportDraftMessages?.[reportAction.reportActionID]?.message; - const actionStable = useStableReportActionForReportActionItem(reportAction); + 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. + */ + const action: ReportAction = useMemo( + () => + ({ + reportActionID: reportAction.reportActionID, + message: reportAction.message, + pendingAction: reportAction.pendingAction, + actionName: reportAction.actionName, + errors: reportAction.errors, + originalMessage, + childCommenterCount: reportAction.childCommenterCount, + linkMetadata: reportAction.linkMetadata, + childReportID: reportAction.childReportID, + childLastVisibleActionCreated: reportAction.childLastVisibleActionCreated, + error: reportAction.error, + created: reportAction.created, + actorAccountID: reportAction.actorAccountID, + adminAccountID: reportAction.adminAccountID, + childVisibleActionCount: reportAction.childVisibleActionCount, + childOldestFourAccountIDs: reportAction.childOldestFourAccountIDs, + childType: reportAction.childType, + person: reportAction.person, + isOptimisticAction: reportAction.isOptimisticAction, + delegateAccountID: reportAction.delegateAccountID, + previousMessage: reportAction.previousMessage, + isAttachmentWithText: reportAction.isAttachmentWithText, + isOriginalReportDeleted: reportAction.isOriginalReportDeleted, + childStateNum: reportAction.childStateNum, + childStatusNum: reportAction.childStatusNum, + childReportName: reportAction.childReportName, + childManagerAccountID: reportAction.childManagerAccountID, + childMoneyRequestCount: reportAction.childMoneyRequestCount, + childOwnerAccountID: reportAction.childOwnerAccountID, + }) as ReportAction, + [ + reportAction.reportActionID, + reportAction.message, + reportAction.pendingAction, + reportAction.actionName, + reportAction.errors, + reportAction.childCommenterCount, + reportAction.linkMetadata, + reportAction.childReportID, + reportAction.childLastVisibleActionCreated, + reportAction.error, + reportAction.created, + reportAction.actorAccountID, + reportAction.adminAccountID, + reportAction.childVisibleActionCount, + reportAction.childOldestFourAccountIDs, + reportAction.childType, + reportAction.person, + reportAction.isOptimisticAction, + reportAction.delegateAccountID, + reportAction.previousMessage, + reportAction.isAttachmentWithText, + reportAction.isOriginalReportDeleted, + reportAction.childStateNum, + reportAction.childStatusNum, + reportAction.childReportName, + reportAction.childManagerAccountID, + reportAction.childMoneyRequestCount, + reportAction.childOwnerAccountID, + originalMessage, + ], + ); const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && (!isTransactionThread(parentReportAction) || isSentMoneyReportAction(parentReportAction)); @@ -98,7 +167,7 @@ function ReportActionsListItemRenderer({ parentReportAction={parentReportAction} reportID={report.reportID} report={report} - action={actionStable} + action={action} transactionThreadReport={transactionThreadReport} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} @@ -115,7 +184,7 @@ function ReportActionsListItemRenderer({ report={report} transactionThreadReport={transactionThreadReport} parentReportActionForTransactionThread={parentReportActionForTransactionThread} - action={actionStable} + action={action} linkedReportActionID={linkedReportActionID} displayAsGroup={displayAsGroup} shouldDisplayNewMarker={shouldDisplayNewMarker} diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 74067d888cba..da5c1fc61ce6 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -47,4 +47,88 @@ function openExpenseReportIDsSelector(reports: OnyxCollection): OpenExpe return openExpenseReportIDMap; } -export {getArchiveReason, getReportChatType, getReportOwnerAccountID, getReportPolicyID, openExpenseReportIDsSelector}; +/** + * Stable `Report` projection for the `ReportActionItem` subtree. + * + * Commented-out `last*` fields are deliberately omitted: they are "heartbeat" fields that + * update on routine activity (incoming/outgoing messages, read receipts, etc.) and would + * invalidate the memo on every chat heartbeat without any of the item subtree actually + * reading them. Everything else stays in the projection so consumer utils that read + * Report fields keep working without surprise `undefined`s. + */ +function stableReportSelector(report: OnyxEntry): 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, + // lastMessageText: report.lastMessageText, + // lastVisibleActionCreated: report.lastVisibleActionCreated, + // lastReadTime: report.lastReadTime, + // lastReadSequenceNumber: report.lastReadSequenceNumber, + // lastMentionedTime: report.lastMentionedTime, + 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, + // lastVisibleActionLastModified: report.lastVisibleActionLastModified, + // lastMessageHtml: report.lastMessageHtml, + // lastActorAccountID: report.lastActorAccountID, + // lastActionType: report.lastActionType, + 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, + } as Report; +} + +export {getArchiveReason, getReportChatType, getReportOwnerAccountID, getReportPolicyID, openExpenseReportIDsSelector, stableReportSelector}; diff --git a/tests/unit/useStableReportActionForReportActionItemTest.ts b/tests/unit/useStableReportActionForReportActionItemTest.ts deleted file mode 100644 index 1254ebbaeb1e..000000000000 --- a/tests/unit/useStableReportActionForReportActionItemTest.ts +++ /dev/null @@ -1,143 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; -import useStableReportActionForReportActionItem from '@hooks/useStableReportActionForReportActionItem'; -import {getOriginalMessage} from '@libs/ReportActionsUtils'; -import CONST from '@src/CONST'; -import type {ReportAction} from '@src/types/onyx'; - -function buildAction(overrides: Partial = {}): ReportAction { - return { - reportActionID: 'a-1', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created: '2026-05-13 12:00:00', - actorAccountID: 10, - message: [{type: 'COMMENT', html: 'hello', text: 'hello'}], - originalMessage: {html: 'hello', whisperedTo: []}, - person: [{type: 'TEXT', style: 'strong', text: 'Tester'}], - ...overrides, - } as ReportAction; -} - -const EXPECTED_ACTION_KEYS = [ - 'reportActionID', - 'message', - 'pendingAction', - 'actionName', - 'errors', - 'originalMessage', - 'childCommenterCount', - 'linkMetadata', - 'childReportID', - 'childLastVisibleActionCreated', - 'error', - 'created', - 'actorAccountID', - 'adminAccountID', - 'childVisibleActionCount', - 'childOldestFourAccountIDs', - 'childType', - 'person', - 'isOptimisticAction', - 'delegateAccountID', - 'previousMessage', - 'isAttachmentWithText', - 'isOriginalReportDeleted', - 'childStateNum', - 'childStatusNum', - 'childReportName', - 'childManagerAccountID', - 'childMoneyRequestCount', - 'childOwnerAccountID', -].sort(); - -const ALLOW_LIST_CASES: Array<[string, unknown]> = [ - ['reportActionID', 'a-2'], - ['message', [{type: 'COMMENT', html: 'world', text: 'world'}]], - ['pendingAction', 'add'], - ['actionName', CONST.REPORT.ACTIONS.TYPE.IOU], - ['errors', {foo: 'err'}], - ['originalMessage', {html: 'world', whisperedTo: []}], - ['childCommenterCount', 5], - ['linkMetadata', [{href: 'x'}]], - ['childReportID', 'child-1'], - ['childLastVisibleActionCreated', '2026-05-13 13:00:00'], - ['error', 'oops'], - ['created', '2026-05-13 14:00:00'], - ['actorAccountID', 99], - ['adminAccountID', 100], - ['childVisibleActionCount', 3], - ['childOldestFourAccountIDs', '1,2,3,4'], - ['childType', 'chat'], - ['person', [{type: 'TEXT', style: 'strong', text: 'Other'}]], - ['isOptimisticAction', true], - ['delegateAccountID', 42], - ['previousMessage', [{type: 'COMMENT', html: 'prev', text: 'prev'}]], - ['isAttachmentWithText', true], - ['isOriginalReportDeleted', true], - ['childStateNum', 1], - ['childStatusNum', 1], - ['childReportName', 'child report'], - ['childManagerAccountID', 50], - ['childMoneyRequestCount', 2], - ['childOwnerAccountID', 60], -]; - -// Real ReportAction fields the hook deliberately omits from its projection (no descendant reads them). -// Churn on these must not invalidate the memo. -const DENY_LIST_CASES: Array<[string, unknown]> = [ - ['lastModified', '2026-05-13 12:00:01'], - ['automatic', true], - ['avatar', 'http://avatar'], - ['shouldShow', false], - ['isLoading', true], - ['accountID', 99], - ['whisperedTo', [1, 2]], -]; - -describe('useStableReportActionForReportActionItem', () => { - it('projects only the documented allow-list keys', () => { - const {result} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { - initialProps: {reportAction: buildAction()}, - }); - expect(Object.keys(result.current).sort()).toEqual(EXPECTED_ACTION_KEYS); - }); - - it.each(ALLOW_LIST_CASES)('flips the projection ref when `%s` changes', (field, nextValue) => { - const initial = buildAction(); - const {result, rerender} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { - initialProps: {reportAction: initial}, - }); - const firstRef = result.current; - - rerender({reportAction: {...initial, [field]: nextValue} as ReportAction}); - expect(result.current).not.toBe(firstRef); - }); - - it.each(DENY_LIST_CASES)('holds the projection ref when `%s` churns', (field, nextValue) => { - const initial = buildAction(); - const {result, rerender} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { - initialProps: {reportAction: initial}, - }); - const firstRef = result.current; - - rerender({reportAction: {...initial, [field]: nextValue} as ReportAction}); - expect(result.current).toBe(firstRef); - }); - - it('precomputes originalMessage on the projection', () => { - const {result} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { - initialProps: {reportAction: buildAction()}, - }); - expect(getOriginalMessage(result.current)).toEqual({html: 'hello', whisperedTo: []}); - }); - - it('keeps the projection ref stable when the upstream top-level ref flips but consumed fields are unchanged', () => { - const initial = buildAction(); - const {result, rerender} = renderHook(({reportAction}) => useStableReportActionForReportActionItem(reportAction), { - initialProps: {reportAction: initial}, - }); - const firstRef = result.current; - - rerender({reportAction: {...initial} as ReportAction}); - expect(result.current).toBe(firstRef); - }); -}); diff --git a/tests/unit/useStableReportForReportActionItemTest.ts b/tests/unit/useStableReportForReportActionItemTest.ts deleted file mode 100644 index 215a041453ab..000000000000 --- a/tests/unit/useStableReportForReportActionItemTest.ts +++ /dev/null @@ -1,186 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; -import useStableReportForReportActionItem from '@hooks/useStableReportForReportActionItem'; -import type {Report} from '@src/types/onyx'; - -function buildReport(overrides: Partial = {}): Report { - return { - reportID: 'r-1', - type: 'chat', - chatType: '' as Report['chatType'], - policyID: 'p-1', - ownerAccountID: 10, - parentReportID: undefined, - parentReportActionID: undefined, - stateNum: 0, - statusNum: 0, - participants: {p10: {notificationPreference: 'always'}, p20: {notificationPreference: 'always'}}, - permissions: ['read', 'write'], - reportName: 'Chat', - description: '', - total: 0, - currency: 'USD', - ...overrides, - } as Report; -} - -const EXPECTED_REPORT_KEYS = [ - 'reportID', - 'chatReportID', - 'isWaitingOnBankAccount', - 'permissions', - 'policyID', - 'ownerAccountID', - 'parentReportID', - 'parentReportActionID', - 'type', - 'chatType', - 'stateNum', - 'statusNum', - 'isDeletedParentAction', - 'pendingFields', - 'participants', - 'errorFields', - 'reportName', - 'description', - 'managerID', - 'total', - 'nonReimbursableTotal', - 'policyAvatar', - 'fieldList', - 'iouReportID', - 'isCancelledIOU', - 'isOwnPolicyExpenseChat', - 'writeCapability', - 'currency', - 'visibility', - 'avatarUrl', - 'policyName', - 'transactionCount', - 'unheldTotal', - 'created', -].sort(); - -const ALLOW_LIST_CASES: Array<[string, unknown]> = [ - ['reportID', 'r-2'], - ['chatReportID', 'chat-1'], - ['isWaitingOnBankAccount', true], - ['permissions', ['read']], - ['policyID', 'p-2'], - ['ownerAccountID', 99], - ['parentReportID', 'parent-1'], - ['parentReportActionID', 'pa-1'], - ['type', 'iou'], - ['chatType', 'policyAnnounce'], - ['stateNum', 1], - ['statusNum', 1], - ['isDeletedParentAction', true], - ['pendingFields', {reportName: 'add'}], - ['participants', {p30: {notificationPreference: 'always'}}], - ['errorFields', {currency: {foo: 'err'}}], - ['reportName', 'Different name'], - ['description', 'a desc'], - ['managerID', 42], - ['total', 1000], - ['nonReimbursableTotal', 500], - ['policyAvatar', 'avatar-url'], - ['fieldList', {foo: 'bar'}], - ['iouReportID', 'iou-1'], - ['isCancelledIOU', true], - ['isOwnPolicyExpenseChat', true], - ['writeCapability', 'admins'], - ['currency', 'EUR'], - ['visibility', 'public'], - ['avatarUrl', 'http://avatar'], - ['policyName', 'Different policy'], - ['transactionCount', 5], - ['unheldTotal', 200], - ['created', '2026-05-13 12:00:00'], -]; - -// Mirrors the "Never add" list in useStableReportForReportActionItem.ts: heartbeat-style fields -// that update on routine activity. These must not invalidate the memo or downstream re-renders cascade. -const DENY_LIST_CASES: Array<[string, unknown]> = [ - ['lastReadTime', '2026-05-13 12:00:00'], - ['lastVisibleActionCreated', '2026-05-13 12:00:01'], - ['lastVisibleActionLastModified', '2026-05-13 12:00:02'], - ['lastMessageText', 'msg'], - ['lastMessageHtml', '

msg

'], - ['lastActorAccountID', 99], - ['lastActionType', 'add'], -]; - -describe('useStableReportForReportActionItem', () => { - it('returns undefined when reportID is missing on an otherwise populated blob', () => { - const {result} = renderHook(({report}) => useStableReportForReportActionItem(report), { - initialProps: {report: buildReport({reportID: undefined})}, - }); - expect(result.current).toBeUndefined(); - }); - - it('projects only the documented allow-list keys', () => { - const {result} = renderHook(({report}) => useStableReportForReportActionItem(report), { - initialProps: {report: buildReport()}, - }); - expect(Object.keys(result.current ?? {}).sort()).toEqual(EXPECTED_REPORT_KEYS); - }); - - it.each(ALLOW_LIST_CASES)('flips the projection ref when `%s` changes', (field, nextValue) => { - const initial = buildReport(); - const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { - initialProps: {report: initial}, - }); - const firstRef = result.current; - - rerender({report: {...initial, [field]: nextValue} as Report}); - expect(result.current).not.toBe(firstRef); - }); - - it.each(DENY_LIST_CASES)('holds the projection ref when `%s` churns', (field, nextValue) => { - const initial = buildReport(); - const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { - initialProps: {report: initial}, - }); - const firstRef = result.current; - - rerender({report: {...initial, [field]: nextValue} as Report}); - expect(result.current).toBe(firstRef); - }); - - it('coerces managerID 0 to undefined and keeps the ref stable through the 0 -> undefined reconciliation', () => { - // Backend ships managerID: 0 on chat reports without a manager; a later Onyx merge of - // `{managerID: null}` deletes the key. Both must coerce to undefined for the memo to hold. - const withZero = buildReport({managerID: 0}); - const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { - initialProps: {report: withZero}, - }); - const firstRef = result.current; - expect(firstRef?.managerID).toBeUndefined(); - - rerender({report: {...withZero, managerID: undefined}}); - - expect(result.current).toBe(firstRef); - expect(result.current?.managerID).toBeUndefined(); - }); - - it('keeps the projection ref stable when the upstream top-level ref flips but consumed primitives are unchanged', () => { - const initial = buildReport(); - const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { - initialProps: {report: initial}, - }); - const firstRef = result.current; - - rerender({report: {...initial} as Report}); - expect(result.current).toBe(firstRef); - }); - - it('holds the projection ref when `permissions` ref flips but contents are identical', () => { - const initial = buildReport({permissions: ['read', 'write']}); - const {result, rerender} = renderHook(({report}) => useStableReportForReportActionItem(report), { - initialProps: {report: initial}, - }); - const firstRef = result.current; - - rerender({report: {...initial, permissions: ['read', 'write']} as Report}); - expect(result.current).toBe(firstRef); - }); -}); From 92dfc1024f89ac3e2a69182ebac6b7a88b4cfda3 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 14 May 2026 18:06:16 +0200 Subject: [PATCH 3/8] move selector to separate file --- .../MoneyRequestReportActionsList.tsx | 2 +- .../SearchList/ListItem/ChatListItem.tsx | 3 +- src/pages/inbox/report/ReportActionItem.tsx | 2 +- src/pages/inbox/report/ReportActionsList.tsx | 20 +++-- src/selectors/Report.ts | 86 +----------------- src/selectors/stableReportSelector.ts | 87 +++++++++++++++++++ 6 files changed, 103 insertions(+), 97 deletions(-) create mode 100644 src/selectors/stableReportSelector.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 71c7ac9f8da8..2ffb9f966b12 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,6 +1,6 @@ /* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; -import {stableReportSelector} from '@selectors/Report'; +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'; diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 62d85bce8fa0..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({ diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 806bd36911bb..0ba6de1a3c6f 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -1,4 +1,4 @@ -import {stableReportSelector} from '@selectors/Report'; +import stableReportSelector from '@selectors/stableReportSelector'; import React, {useCallback} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 69a70bcc96ee..f3ae12c1a374 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,5 +1,5 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; -import {stableReportSelector} from '@selectors/Report'; +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'; @@ -220,7 +220,7 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - const [reportStable = report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, {selector: stableReportSelector}); + const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, {selector: stableReportSelector}); const backTo = route?.params?.backTo as string; const linkedReportActionID = route?.params?.reportActionID; @@ -805,13 +805,15 @@ function ReportActionsList({ isReportArchived={isReportArchived} isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} /> - + {!!reportStable?.reportID && ( + + )} ); }, diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index da5c1fc61ce6..74067d888cba 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -47,88 +47,4 @@ function openExpenseReportIDsSelector(reports: OnyxCollection): OpenExpe return openExpenseReportIDMap; } -/** - * Stable `Report` projection for the `ReportActionItem` subtree. - * - * Commented-out `last*` fields are deliberately omitted: they are "heartbeat" fields that - * update on routine activity (incoming/outgoing messages, read receipts, etc.) and would - * invalidate the memo on every chat heartbeat without any of the item subtree actually - * reading them. Everything else stays in the projection so consumer utils that read - * Report fields keep working without surprise `undefined`s. - */ -function stableReportSelector(report: OnyxEntry): 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, - // lastMessageText: report.lastMessageText, - // lastVisibleActionCreated: report.lastVisibleActionCreated, - // lastReadTime: report.lastReadTime, - // lastReadSequenceNumber: report.lastReadSequenceNumber, - // lastMentionedTime: report.lastMentionedTime, - 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, - // lastVisibleActionLastModified: report.lastVisibleActionLastModified, - // lastMessageHtml: report.lastMessageHtml, - // lastActorAccountID: report.lastActorAccountID, - // lastActionType: report.lastActionType, - 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, - } as Report; -} - -export {getArchiveReason, getReportChatType, getReportOwnerAccountID, getReportPolicyID, openExpenseReportIDsSelector, stableReportSelector}; +export {getArchiveReason, getReportChatType, getReportOwnerAccountID, getReportPolicyID, openExpenseReportIDsSelector}; diff --git a/src/selectors/stableReportSelector.ts b/src/selectors/stableReportSelector.ts new file mode 100644 index 000000000000..e78dc6782fd8 --- /dev/null +++ b/src/selectors/stableReportSelector.ts @@ -0,0 +1,87 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {Report} from '@src/types/onyx'; + +/** + * Stable `Report` projection for the `ReportActionItem` subtree. + * + * `last*` heartbeat fields are deliberately excluded: 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. + * + */ +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, + // lastMessageText: report.lastMessageText, + // lastVisibleActionCreated: report.lastVisibleActionCreated, + // lastReadTime: report.lastReadTime, + // lastReadSequenceNumber: report.lastReadSequenceNumber, + // lastMentionedTime: report.lastMentionedTime, + 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, + // lastVisibleActionLastModified: report.lastVisibleActionLastModified, + // lastMessageHtml: report.lastMessageHtml, + // lastActorAccountID: report.lastActorAccountID, + // lastActionType: report.lastActionType, + 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, + }; +} + +export default stableReportSelector; From 535a35db1ee24270c70aae62602a6e1fa75d5d52 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 14 May 2026 18:08:08 +0200 Subject: [PATCH 4/8] add DuplicateTransactionItem stable selector --- src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 64a36359ed3b..8b23f9859f35 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -13,6 +13,7 @@ import {ReportActionItemActionsContext, ReportActionItemStateContext} from '@pag 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; @@ -26,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) => { From 7287295e817f489244ac4832c58823729590c21a Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 14 May 2026 18:36:57 +0200 Subject: [PATCH 5/8] fix whisper components --- .../report/actionContents/ActionContentRouter.tsx | 4 ++-- .../report/actionContents/ConfirmWhisperContent.tsx | 13 +++++++++---- .../report/actionContents/MentionWhisperContent.tsx | 6 +++++- .../actionContents/ReportMentionWhisperContent.tsx | 13 +++++++++---- tests/unit/WhisperContentMentionContextTest.tsx | 9 ++------- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 0f76b147a249..6d333a8aaea1 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -382,7 +382,7 @@ function ActionContentRouter({ ); @@ -392,7 +392,7 @@ function ActionContentRouter({ ); 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/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} /> , ); From 379ad3f37b23144db0db29621ab2f94105eff661 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 14 May 2026 21:31:02 +0200 Subject: [PATCH 6/8] improve stableReportSelector types --- src/selectors/stableReportSelector.ts | 36 ++++++++++++++++----------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/selectors/stableReportSelector.ts b/src/selectors/stableReportSelector.ts index e78dc6782fd8..a1f2c6683320 100644 --- a/src/selectors/stableReportSelector.ts +++ b/src/selectors/stableReportSelector.ts @@ -1,13 +1,30 @@ import type {OnyxEntry} from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; import type {Report} from '@src/types/onyx'; +type ValidReportKeys> = T; + /** - * Stable `Report` projection for the `ReportActionItem` subtree. - * - * `last*` heartbeat fields are deliberately excluded: they update on routine activity + * 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', + ] +>; + +/** + * Stable `Report` projection for the `ReportActionItem` subtree. */ function stableReportSelector(report: OnyxEntry) { if (!report?.reportID) { @@ -24,11 +41,6 @@ function stableReportSelector(report: OnyxEntry) { hasOutstandingChildTask: report.hasOutstandingChildTask, isOwnPolicyExpenseChat: report.isOwnPolicyExpenseChat, isPinned: report.isPinned, - // lastMessageText: report.lastMessageText, - // lastVisibleActionCreated: report.lastVisibleActionCreated, - // lastReadTime: report.lastReadTime, - // lastReadSequenceNumber: report.lastReadSequenceNumber, - // lastMentionedTime: report.lastMentionedTime, policyAvatar: report.policyAvatar, policyName: report.policyName, oldPolicyName: report.oldPolicyName, @@ -52,10 +64,6 @@ function stableReportSelector(report: OnyxEntry) { // `undefined` keeps the projection stable through that reconciliation. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing managerID: report.managerID || undefined, - // lastVisibleActionLastModified: report.lastVisibleActionLastModified, - // lastMessageHtml: report.lastMessageHtml, - // lastActorAccountID: report.lastActorAccountID, - // lastActionType: report.lastActionType, ownerAccountID: report.ownerAccountID, participants: report.participants, total: report.total, @@ -81,7 +89,7 @@ function stableReportSelector(report: OnyxEntry) { nextStep: report.nextStep, pendingAction: report.pendingAction, pendingFields: report.pendingFields, - }; + } satisfies Omit>; } export default stableReportSelector; From c5c09527b3bf1a424ed7859097069111e08d08b8 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 18 May 2026 10:10:28 +0200 Subject: [PATCH 7/8] improve stableReportSelector type --- src/selectors/stableReportSelector.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/selectors/stableReportSelector.ts b/src/selectors/stableReportSelector.ts index a1f2c6683320..f70c8a76ee44 100644 --- a/src/selectors/stableReportSelector.ts +++ b/src/selectors/stableReportSelector.ts @@ -23,6 +23,8 @@ type ExcludedFields = ValidReportKeys< ] >; +type StableReport = Omit>; + /** * Stable `Report` projection for the `ReportActionItem` subtree. */ @@ -89,7 +91,7 @@ function stableReportSelector(report: OnyxEntry) { nextStep: report.nextStep, pendingAction: report.pendingAction, pendingFields: report.pendingFields, - } satisfies Omit>; + } satisfies Record & StableReport; } export default stableReportSelector; From 6af05f2a2d19722a32ca52d87e8b739f7dc438bb Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 18 May 2026 13:21:50 +0200 Subject: [PATCH 8/8] update stableReportSelector doc --- src/selectors/stableReportSelector.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/selectors/stableReportSelector.ts b/src/selectors/stableReportSelector.ts index f70c8a76ee44..05308e848539 100644 --- a/src/selectors/stableReportSelector.ts +++ b/src/selectors/stableReportSelector.ts @@ -26,7 +26,14 @@ type ExcludedFields = ValidReportKeys< type StableReport = Omit>; /** - * Stable `Report` projection for the `ReportActionItem` subtree. + * 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) {