From e04975911deb57be1af9ea776a288fd5779426c6 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 12 Feb 2026 22:01:13 +0200 Subject: [PATCH 01/15] Pass optimistic reportActionID to onSubmit --- src/libs/actions/Report/index.ts | 32 ++++++++++++++++--- .../ReportActionCompose.tsx | 5 +-- src/pages/inbox/report/ReportFooter.tsx | 3 +- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 48a6da85f2f4b..d5b348e0d80ff 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -280,6 +280,7 @@ type AddCommentParams = { shouldPlaySound?: boolean; isInSidePanel?: boolean; pregeneratedResponseParams?: PregeneratedResponseParams; + reportActionID?: string; }; type AddActionsParams = { @@ -292,6 +293,7 @@ type AddActionsParams = { file?: FileObject; isInSidePanel?: boolean; pregeneratedResponseParams?: PregeneratedResponseParams; + reportActionID?: string; }; type AddAttachmentWithCommentParams = { @@ -609,7 +611,18 @@ function buildOptimisticResolvedFollowups(reportAction: OnyxEntry) * @param isInSidePanel - Whether the comment is being added from the side panel * @param pregeneratedResponseParams - Optional params for pre-generated response (API only, no optimistic action - used when response display is delayed) */ -function addActions({report, notifyReportID, ancestors, timezoneParam, currentUserAccountID, text = '', file, isInSidePanel = false, pregeneratedResponseParams}: AddActionsParams) { +function addActions({ + report, + notifyReportID, + ancestors, + timezoneParam, + currentUserAccountID, + text = '', + file, + isInSidePanel = false, + pregeneratedResponseParams, + reportActionID, +}: AddActionsParams) { if (!report?.reportID) { return; } @@ -620,7 +633,7 @@ function addActions({report, notifyReportID, ancestors, timezoneParam, currentUs let commandName: typeof WRITE_COMMANDS.ADD_COMMENT | typeof WRITE_COMMANDS.ADD_ATTACHMENT | typeof WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT = WRITE_COMMANDS.ADD_COMMENT; if (text && !file) { - const reportComment = buildOptimisticAddCommentReportAction(text, undefined, undefined, undefined, reportID); + const reportComment = buildOptimisticAddCommentReportAction(text, undefined, undefined, undefined, reportID, reportActionID); reportCommentAction = reportComment.reportAction; reportCommentText = reportComment.commentText; } @@ -848,11 +861,22 @@ function addAttachmentWithComment({ } /** Add a single comment to a report */ -function addComment({report, notifyReportID, ancestors, text, timezoneParam, currentUserAccountID, shouldPlaySound, isInSidePanel, pregeneratedResponseParams}: AddCommentParams) { +function addComment({ + report, + notifyReportID, + ancestors, + text, + timezoneParam, + currentUserAccountID, + shouldPlaySound, + isInSidePanel, + pregeneratedResponseParams, + reportActionID, +}: AddCommentParams) { if (shouldPlaySound) { playSound(SOUNDS.DONE); } - addActions({report, notifyReportID, ancestors, timezoneParam, currentUserAccountID, text, isInSidePanel, pregeneratedResponseParams}); + addActions({report, notifyReportID, ancestors, timezoneParam, currentUserAccountID, text, isInSidePanel, pregeneratedResponseParams, reportActionID}); } function reportActionsExist(reportID: string): boolean { diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 54f82235396be..f81a1fa7a4612 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -38,6 +38,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {rand64} from '@libs/NumberUtils'; import Performance from '@libs/Performance'; import {getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { @@ -91,7 +92,7 @@ type SuggestionsRef = { type ReportActionComposeProps = Pick & { /** A method to call when the form is submitted */ - onSubmit: (newComment: string) => void; + onSubmit: (newComment: string, reportActionID?: string) => void; /** The report currently being looked at */ report: OnyxEntry; @@ -364,7 +365,7 @@ function ReportActionCompose({ [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, }, }); - onSubmit(newCommentTrimmed); + onSubmit(newCommentTrimmed, rand64()); } }, [ diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index be3c0bb05e901..c4d58618ef86e 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -187,7 +187,7 @@ function ReportFooter({ const targetReportAncestors = useAncestors(targetReport); const onSubmitComment = useCallback( - (text: string) => { + (text: string, reportActionID?: string) => { const isTaskCreated = handleCreateTask(text); if (isTaskCreated) { return; @@ -203,6 +203,7 @@ function ReportFooter({ currentUserAccountID: personalDetail.accountID, shouldPlaySound: true, isInSidePanel, + reportActionID, }); }, // eslint-disable-next-line react-hooks/exhaustive-deps From 8b06a7178d2be0fba47189f7a3a94487a3629cca Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 12 Feb 2026 22:24:05 +0200 Subject: [PATCH 02/15] Measure only when the chat is scrolled to the bottom --- .../ReportActionCompose.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index f81a1fa7a4612..85ca696a23e7c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,6 +1,6 @@ import lodashDebounce from 'lodash/debounce'; import noop from 'lodash/noop'; -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -62,6 +62,7 @@ import { import {startSpan} from '@libs/telemetry/activeSpans'; import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import AgentZeroProcessingRequestIndicator from '@pages/inbox/report/AgentZeroProcessingRequestIndicator'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; @@ -166,6 +167,8 @@ function ReportActionCompose({ canBeMissing: true, }); const ancestors = useAncestors(transactionThreadReport ?? report); + const {scrollPosition} = useContext(ActionListContext); + /** * Updates the Highlight state of the composer */ @@ -356,15 +359,18 @@ function ReportActionCompose({ }); attachmentFileRef.current = null; } else { - Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); - startSpan(CONST.TELEMETRY.SPAN_SEND_MESSAGE, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, - [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, - }, - }); + const isScrolledToBottom = !scrollPosition?.offset || scrollPosition.offset < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + if (isScrolledToBottom) { + Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); + startSpan(CONST.TELEMETRY.SPAN_SEND_MESSAGE, { + name: 'send-message', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, + [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, + }, + }); + } onSubmit(newCommentTrimmed, rand64()); } }, @@ -379,6 +385,7 @@ function ReportActionCompose({ personalDetail.timezone, isInSidePanel, onSubmit, + scrollPosition, ], ); From 447467a23f9a03497135952b8a18683d6d636b2a Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 12 Feb 2026 22:28:19 +0200 Subject: [PATCH 03/15] Add reportActionID to the spanID --- .../report/ReportActionCompose/ReportActionCompose.tsx | 5 +++-- src/pages/inbox/report/comment/TextCommentFragment.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 85ca696a23e7c..fbdd85e4a6bdb 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -359,10 +359,11 @@ function ReportActionCompose({ }); attachmentFileRef.current = null; } else { + const reportActionID = rand64(); const isScrolledToBottom = !scrollPosition?.offset || scrollPosition.offset < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; if (isScrolledToBottom) { Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); - startSpan(CONST.TELEMETRY.SPAN_SEND_MESSAGE, { + startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`, { name: 'send-message', op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, attributes: { @@ -371,7 +372,7 @@ function ReportActionCompose({ }, }); } - onSubmit(newCommentTrimmed, rand64()); + onSubmit(newCommentTrimmed, reportActionID); } }, [ diff --git a/src/pages/inbox/report/comment/TextCommentFragment.tsx b/src/pages/inbox/report/comment/TextCommentFragment.tsx index 6274558145bc6..c2bf5fa238894 100644 --- a/src/pages/inbox/report/comment/TextCommentFragment.tsx +++ b/src/pages/inbox/report/comment/TextCommentFragment.tsx @@ -65,8 +65,10 @@ function TextCommentFragment({fragment, styleAsDeleted, reportActionID, styleAsM useEffect(() => { Performance.markEnd(CONST.TIMING.SEND_MESSAGE, {message: text}); - endSpan(CONST.TELEMETRY.SPAN_SEND_MESSAGE); - }, [text]); + if (reportActionID) { + endSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`); + } + }, [text, reportActionID]); // If the only difference between fragment.text and fragment.html is
tags and emoji tag // on native, we render it as text, not as html From e79e24182ca15973e829e34a5b62e99242d02cee Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 12 Feb 2026 22:31:35 +0200 Subject: [PATCH 04/15] Cleanup orphaned spans --- src/libs/telemetry/activeSpans.ts | 10 +++++++++- src/pages/inbox/ReportScreen.tsx | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/libs/telemetry/activeSpans.ts b/src/libs/telemetry/activeSpans.ts index 6d807b94e4e30..479faa75cae64 100644 --- a/src/libs/telemetry/activeSpans.ts +++ b/src/libs/telemetry/activeSpans.ts @@ -52,8 +52,16 @@ function cancelAllSpans() { } } +function cancelSpansByPrefix(prefix: string) { + for (const [spanId] of activeSpans.entries()) { + if (spanId.startsWith(prefix)) { + cancelSpan(spanId); + } + } +} + function getSpan(spanId: string) { return activeSpans.get(spanId); } -export {startSpan, endSpan, getSpan, cancelSpan, cancelAllSpans}; +export {startSpan, endSpan, getSpan, cancelSpan, cancelAllSpans, cancelSpansByPrefix}; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index cbf87cb8335a1..85df9f7b4d2d6 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -87,7 +87,7 @@ import { isTaskReport, isValidReportIDFromPath, } from '@libs/ReportUtils'; -import {cancelSpan} from '@libs/telemetry/activeSpans'; +import {cancelSpan, cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; import {isNumeric} from '@libs/ValidationUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; import {setShouldShowComposeInput} from '@userActions/Composer'; @@ -652,6 +652,9 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr // We need to cancel telemetry span when user leaves the screen before full report data is loaded cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${reportID}`); + + // Cancel any pending send-message spans to prevent orphaned spans when navigating away + cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE); }; }, [reportID]); From 87b4c9717a64ca22c68fa878ba0f65d6d2a23556 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 12 Feb 2026 22:43:11 +0200 Subject: [PATCH 05/15] Prettier --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index fbdd85e4a6bdb..e325dfbe5ffd2 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -62,10 +62,10 @@ import { import {startSpan} from '@libs/telemetry/activeSpans'; import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import AgentZeroProcessingRequestIndicator from '@pages/inbox/report/AgentZeroProcessingRequestIndicator'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; From 506c5a5ec535a12f5b451763a71112b1277ad965 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Fri, 13 Feb 2026 16:30:10 +0200 Subject: [PATCH 06/15] Refactor the ActionListContext --- src/hooks/useActionListContextValue.ts | 13 +++++++++++++ src/pages/Search/SearchMoneyRequestReportPage.tsx | 9 +++------ src/pages/inbox/ReportScreen.tsx | 8 +++----- src/pages/inbox/ReportScreenContext.ts | 3 ++- .../ReportActionCompose/ReportActionCompose.tsx | 8 +++++--- src/pages/inbox/report/ReportActionsList.tsx | 5 ++++- 6 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useActionListContextValue.ts diff --git a/src/hooks/useActionListContextValue.ts b/src/hooks/useActionListContextValue.ts new file mode 100644 index 0000000000000..7f4c1d1a6b78c --- /dev/null +++ b/src/hooks/useActionListContextValue.ts @@ -0,0 +1,13 @@ +import {useMemo, useRef, useState} from 'react'; +import type {FlatList} from 'react-native'; +import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext'; + +function useActionListContextValue(): ActionListContextType { + const flatListRef = useRef(null); + const [scrollPosition, setScrollPosition] = useState({}); + const scrollOffsetRef = useRef(0); + + return useMemo(() => ({flatListRef, scrollPosition, setScrollPosition, scrollOffsetRef}), [scrollPosition]); +} + +export default useActionListContextValue; diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 4a56ed8e1000b..e69fd238a7377 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -1,7 +1,6 @@ import {PortalHost} from '@gorhom/portal'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {FlatList} from 'react-native'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; @@ -10,6 +9,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; import useShowSuperWideRHPVersion from '@components/WideRHPContextProvider/useShowSuperWideRHPVersion'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; +import useActionListContextValue from '@hooks/useActionListContextValue'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -36,7 +36,6 @@ import {isValidReportIDFromPath} from '@libs/ReportUtils'; import {isDefaultAvatar, isLetterAvatar, isPresetAvatar} from '@libs/UserAvatarUtils'; import Navigation from '@navigation/Navigation'; import ReactionListWrapper from '@pages/inbox/ReactionListWrapper'; -import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {createTransactionThreadReport, openReport, updateLastVisitTime} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -99,9 +98,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); - const [scrollPosition, setScrollPosition] = useState({}); - const flatListRef = useRef(null); - const actionListValue = useMemo((): ActionListContextType => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]); + const actionListValue = useActionListContextValue(); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 85df9f7b4d2d6..c2e15f7ab1638 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -3,7 +3,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; import {deepEqual} from 'fast-equals'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {FlatList, ViewStyle} from 'react-native'; +import type {ViewStyle} from 'react-native'; // We use Animated for all functionality related to wide RHP to make it easier // to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. // eslint-disable-next-line no-restricted-imports @@ -22,6 +22,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useShowWideRHPVersion from '@components/WideRHPContextProvider/useShowWideRHPVersion'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; +import useActionListContextValue from '@hooks/useActionListContextValue'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; @@ -113,7 +114,6 @@ import useReportWasDeleted from './hooks/useReportWasDeleted'; import ReactionListWrapper from './ReactionListWrapper'; import ReportActionsView from './report/ReportActionsView'; import ReportFooter from './report/ReportFooter'; -import type {ActionListContextType, ScrollPosition} from './ReportScreenContext'; import {ActionListContext} from './ReportScreenContext'; type ReportScreenNavigationProps = @@ -167,7 +167,6 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const prevIsFocused = usePrevious(isFocused); const [firstRender, setFirstRender] = useState(true); const isSkippingOpenReport = useRef(false); - const flatListRef = useRef(null); const hasCreatedLegacyThreadRef = useRef(false); const {isBetaEnabled} = usePermissions(); const {isOffline} = useNetwork(); @@ -317,7 +316,6 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`, {canBeMissing: true}); const [isBannerVisible, setIsBannerVisible] = useState(true); - const [scrollPosition, setScrollPosition] = useState({}); const viewportOffsetTop = useViewportOffsetTop(); @@ -879,7 +877,7 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr }; }, [report?.reportID, didSubscribeToReportLeavingEvents, reportIDFromRoute, report?.pendingFields, currentUserAccountID]); - const actionListValue = useMemo((): ActionListContextType => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]); + const actionListValue = useActionListContextValue(); // This helps in tracking from the moment 'route' triggers useMemo until isLoadingInitialReportActions becomes true. It prevents blinking when loading reportActions from cache. useEffect(() => { diff --git a/src/pages/inbox/ReportScreenContext.ts b/src/pages/inbox/ReportScreenContext.ts index fc1d5ab6de748..ec56c097eeeb6 100644 --- a/src/pages/inbox/ReportScreenContext.ts +++ b/src/pages/inbox/ReportScreenContext.ts @@ -21,10 +21,11 @@ type ActionListContextType = { flatListRef: FlatListRefType; scrollPosition: ScrollPosition | null; setScrollPosition: (position: {offset: number}) => void; + scrollOffsetRef: RefObject; }; type ReactionListContextType = RefObject | null; -const ActionListContext = createContext({flatListRef: null, scrollPosition: null, setScrollPosition: () => {}}); +const ActionListContext = createContext({flatListRef: null, scrollPosition: null, setScrollPosition: () => {}, scrollOffsetRef: {current: 0}}); const ReactionListContext = createContext(null); export {ActionListContext, ReactionListContext}; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index e325dfbe5ffd2..b66f548f2708a 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -167,7 +167,7 @@ function ReportActionCompose({ canBeMissing: true, }); const ancestors = useAncestors(transactionThreadReport ?? report); - const {scrollPosition} = useContext(ActionListContext); + const {scrollOffsetRef} = useContext(ActionListContext); /** * Updates the Highlight state of the composer @@ -360,7 +360,9 @@ function ReportActionCompose({ attachmentFileRef.current = null; } else { const reportActionID = rand64(); - const isScrolledToBottom = !scrollPosition?.offset || scrollPosition.offset < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + + // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). + const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; if (isScrolledToBottom) { Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`, { @@ -386,7 +388,7 @@ function ReportActionCompose({ personalDetail.timezone, isInSidePanel, onSubmit, - scrollPosition, + scrollOffsetRef, ], ); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 2824662771a4d..5405ad6fddccf 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -2,7 +2,7 @@ import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/Vir import {useIsFocused, useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; -import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -61,6 +61,7 @@ import { } from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import variables from '@styles/variables'; import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -172,6 +173,7 @@ function ReportActionsList({ const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const route = useRoute>(); const reportScrollManager = useReportScrollManager(); + const {scrollOffsetRef} = useContext(ActionListContext); const userActiveSince = useRef(DateUtils.getDBTime()); const lastMessageTime = useRef(null); const [isVisible, setIsVisible] = useState(Visibility.isVisible); @@ -365,6 +367,7 @@ function ReportActionsList({ isInverted: true, onTrackScrolling: (event: NativeSyntheticEvent) => { scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; onScroll?.(event); if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { setShouldScrollToEndAfterLayout(false); From f756382f3576e32e5d8e44f48ab9256527a5d1b4 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Fri, 13 Feb 2026 16:36:31 +0200 Subject: [PATCH 07/15] send-message span into a separate useEffect --- src/pages/inbox/report/comment/TextCommentFragment.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/comment/TextCommentFragment.tsx b/src/pages/inbox/report/comment/TextCommentFragment.tsx index c2bf5fa238894..75daa18aa0e63 100644 --- a/src/pages/inbox/report/comment/TextCommentFragment.tsx +++ b/src/pages/inbox/report/comment/TextCommentFragment.tsx @@ -65,10 +65,12 @@ function TextCommentFragment({fragment, styleAsDeleted, reportActionID, styleAsM useEffect(() => { Performance.markEnd(CONST.TIMING.SEND_MESSAGE, {message: text}); + }, [text]); + useEffect(() => { if (reportActionID) { endSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`); } - }, [text, reportActionID]); + }, [reportActionID]); // If the only difference between fragment.text and fragment.html is
tags and emoji tag // on native, we render it as text, not as html From b5988646ae757c5c7bcc275fd17ab4f716701a0f Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Fri, 13 Feb 2026 16:49:17 +0200 Subject: [PATCH 08/15] Fix ts and lint checks --- src/pages/inbox/report/comment/TextCommentFragment.tsx | 5 +++-- tests/perf-test/ReportActionsList.perf-test.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/comment/TextCommentFragment.tsx b/src/pages/inbox/report/comment/TextCommentFragment.tsx index 75daa18aa0e63..1a692b9517473 100644 --- a/src/pages/inbox/report/comment/TextCommentFragment.tsx +++ b/src/pages/inbox/report/comment/TextCommentFragment.tsx @@ -67,9 +67,10 @@ function TextCommentFragment({fragment, styleAsDeleted, reportActionID, styleAsM Performance.markEnd(CONST.TIMING.SEND_MESSAGE, {message: text}); }, [text]); useEffect(() => { - if (reportActionID) { - endSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`); + if (!reportActionID) { + return; } + endSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`); }, [reportActionID]); // If the only difference between fragment.text and fragment.html is
tags and emoji tag diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 9886df0076876..309e5c289b44a 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -70,7 +70,7 @@ beforeAll(() => const mockOnLayout = jest.fn(); const mockOnScroll = jest.fn(); const mockLoadChats = jest.fn(); -const mockRef = {current: null, flatListRef: null, scrollPosition: null, setScrollPosition: () => {}}; +const mockRef = {current: null, flatListRef: null, scrollPosition: null, setScrollPosition: () => {}, scrollOffsetRef: {current: 0}}; const TEST_USER_ACCOUNT_ID = 1; const TEST_USER_LOGIN = 'test@test.com'; From 9f5a0fd970198843231b156f7df04ba01da476e9 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Fri, 13 Feb 2026 17:17:15 +0200 Subject: [PATCH 09/15] Cleanup spans in SearchMoneyRequestReportPage --- src/pages/Search/SearchMoneyRequestReportPage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index e69fd238a7377..6e545f5b75842 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -33,6 +33,7 @@ import { isMoneyRequestAction, } from '@libs/ReportActionsUtils'; import {isValidReportIDFromPath} from '@libs/ReportUtils'; +import {cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; import {isDefaultAvatar, isLetterAvatar, isPresetAvatar} from '@libs/UserAvatarUtils'; import Navigation from '@navigation/Navigation'; import ReactionListWrapper from '@pages/inbox/ReactionListWrapper'; @@ -194,6 +195,11 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { useEffect(() => { hasCreatedLegacyThreadRef.current = false; + + return () => { + // Cancel any pending send-message spans to prevent orphaned spans when navigating away + cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE); + }; }, [reportIDFromRoute]); // Create transaction thread for legacy transactions that don't have one yet. From 334bca96b5cdd1db1ad4b84adc41666443aa90bd Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Mon, 16 Feb 2026 19:01:16 +0200 Subject: [PATCH 10/15] Rename variable --- src/libs/telemetry/activeSpans.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/telemetry/activeSpans.ts b/src/libs/telemetry/activeSpans.ts index 479faa75cae64..57694ff3b5c09 100644 --- a/src/libs/telemetry/activeSpans.ts +++ b/src/libs/telemetry/activeSpans.ts @@ -53,9 +53,9 @@ function cancelAllSpans() { } function cancelSpansByPrefix(prefix: string) { - for (const [spanId] of activeSpans.entries()) { - if (spanId.startsWith(prefix)) { - cancelSpan(spanId); + for (const [spanID] of activeSpans.entries()) { + if (spanID.startsWith(prefix)) { + cancelSpan(spanID); } } } From a94dcb2530ef7ecd9126cda3cba21528e1fa5a22 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Mon, 16 Feb 2026 19:15:12 +0200 Subject: [PATCH 11/15] Remove unnecessary useMemo --- src/hooks/useActionListContextValue.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useActionListContextValue.ts b/src/hooks/useActionListContextValue.ts index 7f4c1d1a6b78c..b25d3b9fce2da 100644 --- a/src/hooks/useActionListContextValue.ts +++ b/src/hooks/useActionListContextValue.ts @@ -1,4 +1,4 @@ -import {useMemo, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import type {FlatList} from 'react-native'; import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext'; @@ -7,7 +7,7 @@ function useActionListContextValue(): ActionListContextType { const [scrollPosition, setScrollPosition] = useState({}); const scrollOffsetRef = useRef(0); - return useMemo(() => ({flatListRef, scrollPosition, setScrollPosition, scrollOffsetRef}), [scrollPosition]); + return {flatListRef, scrollPosition, setScrollPosition, scrollOffsetRef}; } export default useActionListContextValue; From ec0e4c449ca89ffc0ccebd568a4cc0d10e2416a9 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Mon, 16 Feb 2026 19:51:17 +0200 Subject: [PATCH 12/15] refactor: consolidate scroll state into refs, remove duplicate tracking --- src/hooks/useActionListContextValue.ts | 6 +-- .../useReportScrollManager/index.native.ts | 47 ++++++++----------- src/hooks/useReportScrollManager/index.ts | 44 ++++++++--------- src/pages/inbox/ReportScreenContext.ts | 5 +- src/pages/inbox/report/ReportActionsList.tsx | 16 +++---- .../perf-test/ReportActionsList.perf-test.tsx | 2 +- 6 files changed, 51 insertions(+), 69 deletions(-) diff --git a/src/hooks/useActionListContextValue.ts b/src/hooks/useActionListContextValue.ts index b25d3b9fce2da..ba73ff804cc07 100644 --- a/src/hooks/useActionListContextValue.ts +++ b/src/hooks/useActionListContextValue.ts @@ -1,13 +1,13 @@ -import {useRef, useState} from 'react'; +import {useRef} from 'react'; import type {FlatList} from 'react-native'; import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext'; function useActionListContextValue(): ActionListContextType { const flatListRef = useRef(null); - const [scrollPosition, setScrollPosition] = useState({}); + const scrollPositionRef = useRef({}); const scrollOffsetRef = useRef(0); - return {flatListRef, scrollPosition, setScrollPosition, scrollOffsetRef}; + return {flatListRef, scrollPositionRef, scrollOffsetRef}; } export default useActionListContextValue; diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 39e5a1b66ee7d..180196dd0b3ed 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,44 +1,39 @@ -import {useCallback, useContext} from 'react'; +import {useContext} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { - const {flatListRef, setScrollPosition} = useContext(ActionListContext); + const {flatListRef, scrollPositionRef} = useContext(ActionListContext); /** * Scroll to the provided index. */ - const scrollToIndex = useCallback( - (index: number) => { - if (!flatListRef?.current) { - return; - } - - flatListRef.current.scrollToIndex({index}); - }, - [flatListRef], - ); + const scrollToIndex = (index: number) => { + if (!flatListRef?.current) { + return; + } + flatListRef.current.scrollToIndex({index}); + }; /** * Scroll to the bottom of the inverted FlatList. * When FlatList is inverted it's "bottom" is really it's top */ - const scrollToBottom = useCallback(() => { + const scrollToBottom = () => { if (!flatListRef?.current) { return; } - setScrollPosition({offset: 0}); - + scrollPositionRef.current = {offset: 0}; flatListRef.current?.scrollToOffset({animated: false, offset: 0}); - }, [flatListRef, setScrollPosition]); + }; /** * Scroll to the end of the FlatList. */ - const scrollToEnd = useCallback(() => { + const scrollToEnd = () => { if (!flatListRef?.current) { return; } @@ -51,18 +46,14 @@ function useReportScrollManager(): ReportScrollManagerData { } flatListRef.current.scrollToEnd({animated: false}); - }, [flatListRef]); + }; - const scrollToOffset = useCallback( - (offset: number) => { - if (!flatListRef?.current) { - return; - } - - flatListRef.current.scrollToOffset({offset, animated: false}); - }, - [flatListRef], - ); + const scrollToOffset = (offset: number) => { + if (!flatListRef?.current) { + return; + } + flatListRef.current.scrollToOffset({offset, animated: false}); + }; return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; } diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index 30c383e62c2f2..6b888584887cf 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -1,4 +1,4 @@ -import {useCallback, useContext} from 'react'; +import {useContext} from 'react'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import type ReportScrollManagerData from './types'; @@ -8,50 +8,44 @@ function useReportScrollManager(): ReportScrollManagerData { /** * Scroll to the provided index. On non-native implementations we do not want to scroll when we are scrolling because */ - const scrollToIndex = useCallback( - (index: number, isEditing?: boolean) => { - if (!flatListRef?.current || isEditing) { - return; - } + const scrollToIndex = (index: number, isEditing?: boolean) => { + if (!flatListRef?.current || isEditing) { + return; + } - flatListRef.current.scrollToIndex({index, animated: true}); - }, - [flatListRef], - ); + flatListRef.current.scrollToIndex({index, animated: true}); + }; /** * Scroll to the bottom of the inverted FlatList. * When FlatList is inverted it's "bottom" is really it's top */ - const scrollToBottom = useCallback(() => { + const scrollToBottom = () => { if (!flatListRef?.current) { return; } flatListRef.current.scrollToOffset({animated: false, offset: 0}); - }, [flatListRef]); + }; /** * Scroll to the end of the FlatList. */ - const scrollToEnd = useCallback(() => { + const scrollToEnd = () => { if (!flatListRef?.current) { return; } flatListRef.current.scrollToEnd({animated: false}); - }, [flatListRef]); - - const scrollToOffset = useCallback( - (offset: number) => { - if (!flatListRef?.current) { - return; - } - - flatListRef.current.scrollToOffset({animated: true, offset}); - }, - [flatListRef], - ); + }; + + const scrollToOffset = (offset: number) => { + if (!flatListRef?.current) { + return; + } + + flatListRef.current.scrollToOffset({animated: true, offset}); + }; return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; } diff --git a/src/pages/inbox/ReportScreenContext.ts b/src/pages/inbox/ReportScreenContext.ts index ec56c097eeeb6..50b1e3e707f90 100644 --- a/src/pages/inbox/ReportScreenContext.ts +++ b/src/pages/inbox/ReportScreenContext.ts @@ -19,13 +19,12 @@ type ScrollPosition = {offset?: number}; type ActionListContextType = { flatListRef: FlatListRefType; - scrollPosition: ScrollPosition | null; - setScrollPosition: (position: {offset: number}) => void; + scrollPositionRef: RefObject; scrollOffsetRef: RefObject; }; type ReactionListContextType = RefObject | null; -const ActionListContext = createContext({flatListRef: null, scrollPosition: null, setScrollPosition: () => {}, scrollOffsetRef: {current: 0}}); +const ActionListContext = createContext({flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}}); const ReactionListContext = createContext(null); export {ActionListContext, ReactionListContext}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e2f0f8f311065..4b1b8ab15df90 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -216,7 +216,6 @@ function ReportActionsList({ return unsubscribe; }, []); - const scrollingVerticalOffset = useRef(0); const readActionSkipped = useRef(false); const hasHeaderRendered = useRef(false); const linkedReportActionID = route?.params?.reportActionID; @@ -295,7 +294,7 @@ function ReportActionsList({ currentUserAccountID, prevSortedVisibleReportActionsObjects, unreadMarkerTime, - scrollingVerticalOffset: scrollingVerticalOffset.current, + scrollingVerticalOffset: scrollOffsetRef.current, prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, }); if (shouldDisplayNewMarker) { @@ -304,7 +303,7 @@ function ReportActionsList({ } return [null, -1]; - }, [currentUserAccountID, isAnonymousUser, earliestReceivedOfflineMessageIndex, prevSortedVisibleReportActionsObjects, sortedVisibleReportActions, unreadMarkerTime]); + }, [currentUserAccountID, isAnonymousUser, earliestReceivedOfflineMessageIndex, prevSortedVisibleReportActionsObjects, scrollOffsetRef, sortedVisibleReportActions, unreadMarkerTime]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; /** @@ -361,12 +360,11 @@ function ReportActionsList({ const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, - currentVerticalScrollingOffsetRef: scrollingVerticalOffset, + currentVerticalScrollingOffsetRef: scrollOffsetRef, readActionSkippedRef: readActionSkipped, unreadMarkerReportActionIndex, isInverted: true, onTrackScrolling: (event: NativeSyntheticEvent) => { - scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; scrollOffsetRef.current = event.nativeEvent.contentOffset.y; onScroll?.(event); if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { @@ -377,7 +375,7 @@ function ReportActionsList({ useEffect(() => { if ( - scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && + scrollOffsetRef.current < AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionIndex && reportActionSize.current !== sortedVisibleReportActions.length && hasNewestReportAction @@ -387,7 +385,7 @@ function ReportActionsList({ } previousLastIndex.current = lastActionIndex; reportActionSize.current = sortedVisibleReportActions.length; - }, [lastActionIndex, sortedVisibleReportActions.length, reportScrollManager, hasNewestReportAction, linkedReportActionID, setIsFloatingMessageCounterVisible]); + }, [lastActionIndex, sortedVisibleReportActions.length, reportScrollManager, hasNewestReportAction, linkedReportActionID, setIsFloatingMessageCounterVisible, scrollOffsetRef]); useEffect(() => { const shouldTriggerScroll = shouldFocusToTopOnMount && prevHasCreatedActionAdded && !hasCreatedActionAdded; @@ -417,7 +415,7 @@ function ReportActionsList({ // Currently, there's no programmatic way to dismiss the notification center panel. // To handle this, we use the 'referrer' parameter to check if the current navigation is triggered from a notification. const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; - if ((isVisible || isFromNotification) && scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + if ((isVisible || isFromNotification) && scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { readNewestAction(report.reportID); if (isFromNotification) { Navigation.setParams({referrer: undefined}); @@ -448,7 +446,7 @@ function ReportActionsList({ lastMessageTime.current = null; const isArchivedReport = isArchivedNonExpenseReport(report, isReportArchived); - const hasNewMessagesInView = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + const hasNewMessagesInView = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; const hasUnreadReportAction = sortedVisibleReportActions.some( (reportAction) => newMessageTimeReference && diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 309e5c289b44a..72122f8bf02a5 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -70,7 +70,7 @@ beforeAll(() => const mockOnLayout = jest.fn(); const mockOnScroll = jest.fn(); const mockLoadChats = jest.fn(); -const mockRef = {current: null, flatListRef: null, scrollPosition: null, setScrollPosition: () => {}, scrollOffsetRef: {current: 0}}; +const mockRef = {current: null, flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}}; const TEST_USER_ACCOUNT_ID = 1; const TEST_USER_LOGIN = 'test@test.com'; From 27219ed2c0dd2d9698d7aec56754ab41a1185585 Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Mon, 16 Feb 2026 20:34:34 +0200 Subject: [PATCH 13/15] fix: wire scrollOffsetRef in MoneyRequestReportActionsList for scroll guard --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 4b07cd925b7bb..1c367448f525c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -4,7 +4,7 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -59,6 +59,7 @@ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; @@ -261,6 +262,7 @@ function MoneyRequestReportActionsList({ const lastActionIndex = lastAction?.reportActionID; const previousLastIndex = useRef(lastActionIndex); + const {scrollOffsetRef} = useContext(ActionListContext); const scrollingVerticalBottomOffset = useRef(0); const scrollingVerticalTopOffset = useRef(0); const wrapperViewRef = useRef(null); @@ -449,6 +451,7 @@ function MoneyRequestReportActionsList({ * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) */ scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; + scrollOffsetRef.current = scrollingVerticalBottomOffset.current; // We additionally track the top offset to be able to scroll to the new transaction when it's added scrollingVerticalTopOffset.current = contentOffset.y; From d8716a8da0aca51745261f74dd6da9b896a9267f Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 19 Feb 2026 00:36:43 +0200 Subject: [PATCH 14/15] Add clarifying comment for pre-generated reportActionID Co-authored-by: Cursor --- .../inbox/report/ReportActionCompose/ReportActionCompose.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 383ad18059468..203db4db4042c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -360,6 +360,7 @@ function ReportActionCompose({ }); attachmentFileRef.current = null; } else { + // Pre-generate the optimistic reportActionID so we can correlate the Sentry send-message span with the exact message const reportActionID = rand64(); // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). From 106a45d787dc1f3459e1704aba63208d59dd166c Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Thu, 19 Feb 2026 12:46:21 +0200 Subject: [PATCH 15/15] Rename reportActionID to optimisticReportActionID in composer Co-authored-by: Cursor --- .../report/ReportActionCompose/ReportActionCompose.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 203db4db4042c..336512880c385 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -360,14 +360,14 @@ function ReportActionCompose({ }); attachmentFileRef.current = null; } else { - // Pre-generate the optimistic reportActionID so we can correlate the Sentry send-message span with the exact message - const reportActionID = rand64(); + // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message + const optimisticReportActionID = rand64(); // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; if (isScrolledToBottom) { Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`, { + startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { name: 'send-message', op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, attributes: { @@ -376,7 +376,7 @@ function ReportActionCompose({ }, }); } - onSubmit(newCommentTrimmed, reportActionID); + onSubmit(newCommentTrimmed, optimisticReportActionID); } }, [