From 4e586f5eb7eb8f4a523067d5c94b1e65f78b64a6 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 22:02:34 +0200 Subject: [PATCH 1/3] fix: use list-aware message edit scrolling --- .../MoneyRequestReportActionsList.tsx | 44 ++++---- .../ReportActionCompose/useEditMessage.ts | 22 +++- .../inbox/report/ReportActionIndexContext.tsx | 7 ++ .../report/ReportActionItemMessageEdit.tsx | 7 +- ...equestReportActionsListRejectModalTest.tsx | 101 ++++++++++++++++-- tests/ui/ReportActionItemMessageEditTest.tsx | 37 ++++++- tests/unit/hooks/useEditMessage.test.ts | 43 +++++++- 7 files changed, 223 insertions(+), 38 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index c0d170d8153d..298c6fd8e06a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -50,7 +50,7 @@ import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListCon import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; +import ReportActionIndexContext, {ReportActionIsNewestContext, ReportActionScrollToNewestContext} 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'; @@ -636,26 +636,31 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, indexWithinReportActions, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + const isNewestReportAction = indexWithinReportActions === visibleReportActions.length - 1; return ( - - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - shouldHideThreadDividerLine - linkedReportActionID={linkedReportActionID} - isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - /> - + + + + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + shouldHideThreadDividerLine + linkedReportActionID={linkedReportActionID} + isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + + + ); }, [ @@ -671,6 +676,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) shouldShowHarvestCreatedAction, draftReportActionID, isDraftPendingCompletion, + scrollToBottom, ], ); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 4b856d90e1ba..81fb3e7c3d01 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -23,6 +23,7 @@ type UseEditMessageProps = { originalReportID: string | undefined; reportAction: OnyxTypes.ReportAction | null | undefined; shouldScrollToLastMessage?: boolean; + scrollToLastMessage?: () => void; debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; composerRef: React.RefObject; }; @@ -30,7 +31,15 @@ type UseEditMessageProps = { /** * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. */ -function useEditMessage({reportID, originalReportID, reportAction, shouldScrollToLastMessage = false, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { +function useEditMessage({ + reportID, + originalReportID, + reportAction, + shouldScrollToLastMessage = false, + scrollToLastMessage, + debouncedCommentMaxLengthValidation, + composerRef, +}: UseEditMessageProps) { const reportScrollManager = useReportScrollManager(); const {email} = useCurrentUserPersonalDetails(); @@ -51,9 +60,16 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT clearAllReportActionDrafts(); // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. - if (shouldScrollToLastMessage) { - reportScrollManager.scrollToIndex(0); + if (!shouldScrollToLastMessage) { + return; } + + if (scrollToLastMessage) { + scrollToLastMessage(); + return; + } + + reportScrollManager.scrollToIndex(0); } /** diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 1abe9823fcf3..7bd86c71d3c8 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -10,4 +10,11 @@ import {createContext} from 'react'; */ const ReportActionIndexContext = createContext(0); +/** Lets shared list implementations identify their newest report action without assuming an ordering direction. */ +const ReportActionIsNewestContext = createContext(undefined); + +/** Lets shared list implementations provide their own reliable way to reach the newest action. */ +const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); + +export {ReportActionIsNewestContext, ReportActionScrollToNewestContext}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 90634e30cfd3..cf4e4a54fbf1 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -49,7 +49,7 @@ import useComposerSuggestions from './ReportActionCompose/useComposerSuggestions import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; -import ReportActionIndexContext from './ReportActionIndexContext'; +import ReportActionIndexContext, {ReportActionIsNewestContext, ReportActionScrollToNewestContext} from './ReportActionIndexContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; import useDebouncedSaveDraft from './useDebouncedSaveDraft'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; @@ -78,6 +78,8 @@ const DEFAULT_MODAL_VALUE = { function ReportActionItemMessageEdit({action, reportID, originalReportID, policyID, ref}: ReportActionItemMessageEditProps) { const index = useContext(ReportActionIndexContext); + const isNewestOverride = useContext(ReportActionIsNewestContext); + const scrollToNewestAction = useContext(ReportActionScrollToNewestContext); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`); @@ -249,7 +251,8 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy reportID, originalReportID, reportAction: action, - shouldScrollToLastMessage: index === 0, + shouldScrollToLastMessage: isNewestOverride ?? index === 0, + scrollToLastMessage: scrollToNewestAction, debouncedCommentMaxLengthValidation, composerRef, }); diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index da7168b6e9c3..1205308e9e17 100644 --- a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx +++ b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx @@ -11,6 +11,9 @@ import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import type * as InFlightRequests from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; +import {ActionListContextProvider} from '@pages/inbox/ActionListContext'; +import type * as ReportActionIndexContexts from '@pages/inbox/report/ReportActionIndexContext'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -31,6 +34,12 @@ const FAKE_POLICY_ID = 'FAKE_POLICY_001'; const FAKE_ACCOUNT_ID = 15593135; const FAKE_TRANSACTION_ID = 'FAKE_TXN_001'; const FAKE_EMAIL = 'testuser@example.com'; +const MOCK_UNIFIED_LAST_ITEM_INDEX = 37; +const mockScrollToIndex = jest.fn(); +const mockScrollToEnd = jest.fn(); +const mockScrollToOffset = jest.fn(); +let mockIsNewestReportAction: boolean | undefined; +let mockScrollToNewestAction: (() => void) | undefined; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -80,12 +89,38 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionList', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View} = require('react-native'); - return ({listFooterComponent, isLoadingInitialActions}: {listFooterComponent?: React.ReactElement; isLoadingInitialActions: boolean}) => ( - - {isLoadingInitialActions ? : null} - {listFooterComponent} - - ); + const ReactActual = jest.requireActual('react'); + return ({ + listFooterComponent, + isLoadingInitialActions, + listRef, + onLastItemIndexChange, + visibleReportActions, + renderReportAction, + }: { + listFooterComponent?: React.ReactElement; + isLoadingInitialActions: boolean; + listRef: React.Ref<{ + scrollToIndex: typeof mockScrollToIndex; + scrollToEnd: typeof mockScrollToEnd; + scrollToOffset: typeof mockScrollToOffset; + }>; + onLastItemIndexChange?: (index: number) => void; + visibleReportActions: ReportAction[]; + renderReportAction: (reportAction: ReportAction, index: number) => React.ReactElement; + }) => { + ReactActual.useImperativeHandle(listRef, () => ({scrollToIndex: mockScrollToIndex, scrollToEnd: mockScrollToEnd, scrollToOffset: mockScrollToOffset})); + ReactActual.useLayoutEffect(() => onLastItemIndexChange?.(MOCK_UNIFIED_LAST_ITEM_INDEX), [onLastItemIndexChange]); + const newestAction = visibleReportActions.at(-1); + + return ( + + {isLoadingInitialActions ? : null} + {newestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null} + {listFooterComponent} + + ); + }; }); jest.mock('@components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState', () => { @@ -159,7 +194,17 @@ jest.mock('@hooks/useMobileSelectionMode', () => jest.fn(() => true)); jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({shouldUseNarrowLayout: true}))); jest.mock('@hooks/useFilterSelectedTransactions', () => jest.fn()); jest.mock('@hooks/useLoadReportActions', () => jest.fn(() => ({loadOlderChats: jest.fn(), loadNewerChats: jest.fn()}))); -jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => jest.fn(() => null)); +jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => { + const ReactActual = jest.requireActual('react'); + const {ReportActionIsNewestContext: ReportActionIsNewestContextActual, ReportActionScrollToNewestContext: ReportActionScrollToNewestContextActual} = + jest.requireActual('@pages/inbox/report/ReportActionIndexContext'); + + return jest.fn(() => { + mockIsNewestReportAction = ReactActual.useContext(ReportActionIsNewestContextActual); + mockScrollToNewestAction = ReactActual.useContext(ReportActionScrollToNewestContextActual); + return null; + }); +}); jest.mock('@hooks/useParentReportAction', () => jest.fn(() => undefined)); jest.mock('@navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn(() => false)); @@ -223,9 +268,23 @@ const mockReportAction = createMock { return render( - + @@ -250,6 +309,8 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { beforeEach(async () => { jest.clearAllMocks(); + mockIsNewestReportAction = undefined; + mockScrollToNewestAction = undefined; jest.spyOn(NativeNavigation, 'useIsFocused').mockReturnValue(true); mockUseIsReportLoadPending.mockReturnValue(false); mockUseNetwork.mockReturnValue({isOffline: false}); @@ -259,6 +320,30 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { }); }); + it('should use the unified list index when the newest action requests a bottom scroll', async () => { + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_REPORT_ID}` as const]: mockReport, + [`${ONYXKEYS.COLLECTION.POLICY}${FAKE_POLICY_ID}` as const]: mockPolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${FAKE_TRANSACTION_ID}` as const]: mockTransaction, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${FAKE_REPORT_ID}` as const]: { + [mockReportAction.reportActionID]: mockReportAction, + [mockCommentReportAction.reportActionID]: mockCommentReportAction, + }, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${FAKE_REPORT_ID}` as const]: {isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, + [ONYXKEYS.SESSION]: {accountID: FAKE_ACCOUNT_ID, email: FAKE_EMAIL} as Session, + }); + }); + + renderComponent(); + await waitForBatchedUpdatesWithAct(); + + expect(mockIsNewestReportAction).toBe(true); + act(() => mockScrollToNewestAction?.()); + expect(mockScrollToIndex).toHaveBeenCalledWith({index: MOCK_UNIFIED_LAST_ITEM_INDEX, animated: false, viewPosition: 1}); + expect(mockScrollToEnd).not.toHaveBeenCalled(); + }); + it('should show reject educational modal when reject option is selected and explanation has NOT been dismissed', async () => { await act(async () => { await Onyx.multiSet({ diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 302edd2eb643..5bfc321ecaa3 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -9,6 +9,7 @@ import {editReportComment} from '@libs/actions/Report'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import ReportActionIndexContext, {ReportActionIsNewestContext, ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; @@ -86,13 +87,19 @@ function ReportScreenProviders({children}: PropsWithChildren) { return {children}; } -const renderReportActionItemMessageEdit = (props?: Partial) => { +const renderReportActionItemMessageEdit = (props?: Partial, listContext?: {index: number; isNewest: boolean; scrollToNewestAction?: () => void}) => { return render( - + + + + + + + , ); }; @@ -210,5 +217,25 @@ describe('ReportActionCompose Integration Tests', () => { expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-height'); expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-width'); }); + + it('should use the list-specific scroll after saving the newest message', () => { + const scrollToNewestAction = jest.fn(); + renderReportActionItemMessageEdit(undefined, {index: 9, isNewest: true, scrollToNewestAction}); + + fireEvent.changeText(screen.getByTestId('composer'), 'Edited message'); + fireEvent.press(screen.getByLabelText('common.saveChanges')); + + expect(scrollToNewestAction).toHaveBeenCalledTimes(1); + }); + + it('should not scroll after saving a non-newest message at index zero', () => { + const scrollToNewestAction = jest.fn(); + renderReportActionItemMessageEdit(undefined, {index: 0, isNewest: false, scrollToNewestAction}); + + fireEvent.changeText(screen.getByTestId('composer'), 'Edited message'); + fireEvent.press(screen.getByLabelText('common.saveChanges')); + + expect(scrollToNewestAction).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index aebd3cdc0186..e945ebf00b19 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -58,9 +58,10 @@ jest.mock('@hooks/useReportIsArchived', () => ({ default: () => false, })); +const mockScrollToIndex = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, - default: () => ({scrollToIndex: jest.fn()}), + default: () => ({scrollToIndex: mockScrollToIndex}), })); jest.mock('@libs/ReportUtils', () => { @@ -142,4 +143,44 @@ describe('useEditMessage', () => { const args = mockShowDeleteModal.mock.calls.at(0); expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); }); + + it('scrolls to index zero after deleting the newest message draft without a list-specific callback', () => { + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith(0); + }); + + it('uses the list-specific scroll after deleting the newest message draft', () => { + const scrollToLastMessage = jest.fn(); + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true, scrollToLastMessage}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(scrollToLastMessage).toHaveBeenCalledTimes(1); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('does not scroll after deleting a non-newest message draft', () => { + const scrollToLastMessage = jest.fn(); + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: false, scrollToLastMessage}); + + act(() => { + hook.result.current.deleteDraft(); + }); + + expect(scrollToLastMessage).not.toHaveBeenCalled(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); }); From e9e6f3b7f082b882cc101b9bf63e1788776a69d0 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 11 Sep 2026 12:41:58 +0200 Subject: [PATCH 2/3] refactor: consolidate report action position context --- .../MoneyRequestReportActionsList.tsx | 43 ++++++++++--------- .../inbox/report/ReportActionIndexContext.tsx | 32 +++++++++----- .../report/ReportActionItemMessageEdit.tsx | 7 ++- src/pages/inbox/report/ReportActionsList.tsx | 9 ++-- ...equestReportActionsListRejectModalTest.tsx | 4 +- tests/ui/ReportActionItemMessageEditTest.tsx | 19 ++++---- 6 files changed, 64 insertions(+), 50 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 298c6fd8e06a..be319bbead6e 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -50,7 +50,7 @@ import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListCon import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import ReportActionIndexContext, {ReportActionIsNewestContext, ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; +import {ReportActionPositionContextProvider, ReportActionScrollToNewestContext} 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'; @@ -640,26 +640,27 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) return ( - - - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - shouldHideThreadDividerLine - linkedReportActionID={linkedReportActionID} - isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - /> - - + + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + shouldHideThreadDividerLine + linkedReportActionID={linkedReportActionID} + isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + ); }, diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 7bd86c71d3c8..a26c378833d6 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -1,20 +1,30 @@ -import {createContext} from 'react'; +import type {PropsWithChildren} from 'react'; + +import {createContext, useMemo} from 'react'; + +type ReportActionPosition = { + index: number; + isNewest: boolean; +}; /** - * 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). + * Carries an action item's position from the list renderer down to the rare consumers that + * actually need it (e.g. `ReportActionItemMessageEdit` for scrolling 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. + * Using context keeps position data out of the prop signatures of every intermediate component, so + * a 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); - -/** Lets shared list implementations identify their newest report action without assuming an ordering direction. */ -const ReportActionIsNewestContext = createContext(undefined); +const ReportActionIndexContext = createContext({index: 0, isNewest: true}); /** Lets shared list implementations provide their own reliable way to reach the newest action. */ const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); -export {ReportActionIsNewestContext, ReportActionScrollToNewestContext}; +function ReportActionPositionContextProvider({children, index, isNewest}: PropsWithChildren) { + const value = useMemo(() => ({index, isNewest}), [index, isNewest]); + + return {children}; +} + +export {ReportActionPositionContextProvider, ReportActionScrollToNewestContext}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index cf4e4a54fbf1..e753c2d6823c 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -49,7 +49,7 @@ import useComposerSuggestions from './ReportActionCompose/useComposerSuggestions import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; -import ReportActionIndexContext, {ReportActionIsNewestContext, ReportActionScrollToNewestContext} from './ReportActionIndexContext'; +import ReportActionIndexContext, {ReportActionScrollToNewestContext} from './ReportActionIndexContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; import useDebouncedSaveDraft from './useDebouncedSaveDraft'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; @@ -77,8 +77,7 @@ const DEFAULT_MODAL_VALUE = { }; function ReportActionItemMessageEdit({action, reportID, originalReportID, policyID, ref}: ReportActionItemMessageEditProps) { - const index = useContext(ReportActionIndexContext); - const isNewestOverride = useContext(ReportActionIsNewestContext); + const {index, isNewest} = useContext(ReportActionIndexContext); const scrollToNewestAction = useContext(ReportActionScrollToNewestContext); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); @@ -251,7 +250,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy reportID, originalReportID, reportAction: action, - shouldScrollToLastMessage: isNewestOverride ?? index === 0, + shouldScrollToLastMessage: isNewest, scrollToLastMessage: scrollToNewestAction, debouncedCommentMaxLengthValidation, composerRef, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 1f471b3fb6a3..b247b9c00e0b 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -66,7 +66,7 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useEffect, useRef, useState} from 'react'; import FloatingMessageCounter from './FloatingMessageCounter'; -import ReportActionIndexContext from './ReportActionIndexContext'; +import {ReportActionPositionContextProvider} from './ReportActionIndexContext'; import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; @@ -366,7 +366,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; return ( - + )} - + ); }; diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index 1205308e9e17..b99fb74af778 100644 --- a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx +++ b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx @@ -196,11 +196,11 @@ jest.mock('@hooks/useFilterSelectedTransactions', () => jest.fn()); jest.mock('@hooks/useLoadReportActions', () => jest.fn(() => ({loadOlderChats: jest.fn(), loadNewerChats: jest.fn()}))); jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => { const ReactActual = jest.requireActual('react'); - const {ReportActionIsNewestContext: ReportActionIsNewestContextActual, ReportActionScrollToNewestContext: ReportActionScrollToNewestContextActual} = + const {default: ReportActionIndexContextActual, ReportActionScrollToNewestContext: ReportActionScrollToNewestContextActual} = jest.requireActual('@pages/inbox/report/ReportActionIndexContext'); return jest.fn(() => { - mockIsNewestReportAction = ReactActual.useContext(ReportActionIsNewestContextActual); + mockIsNewestReportAction = ReactActual.useContext(ReportActionIndexContextActual).isNewest; mockScrollToNewestAction = ReactActual.useContext(ReportActionScrollToNewestContextActual); return null; }); diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 5bfc321ecaa3..e7468c15412d 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -9,7 +9,7 @@ import {editReportComment} from '@libs/actions/Report'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; -import ReportActionIndexContext, {ReportActionIsNewestContext, ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; +import {ReportActionPositionContextProvider, ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; @@ -91,14 +91,15 @@ const renderReportActionItemMessageEdit = (props?: Partial - - - - - + + + , ); From ed4f64ec2a5fee4054cbb2e831215a85457bbe43 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 11 Sep 2026 12:49:22 +0200 Subject: [PATCH 3/3] refactor: remove report action position memoization --- src/pages/inbox/report/ReportActionIndexContext.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index a26c378833d6..3cc5eab3b727 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -1,6 +1,6 @@ import type {PropsWithChildren} from 'react'; -import {createContext, useMemo} from 'react'; +import {createContext} from 'react'; type ReportActionPosition = { index: number; @@ -21,9 +21,7 @@ const ReportActionIndexContext = createContext({index: 0, const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); function ReportActionPositionContextProvider({children, index, isNewest}: PropsWithChildren) { - const value = useMemo(() => ({index, isNewest}), [index, isNewest]); - - return {children}; + return {children}; } export {ReportActionPositionContextProvider, ReportActionScrollToNewestContext};