From c9095c7d7758430fad87e76936d37f3b88830823 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 7 May 2026 10:43:20 +0200 Subject: [PATCH 01/12] Remove hidden-render workaround --- src/pages/inbox/report/ReportActionsList.tsx | 99 +------------------ src/pages/inbox/report/ReportActionsView.tsx | 1 - .../index.native.tsx | 17 ---- .../StaticReportActionsPreview/index.tsx | 12 --- .../StaticReportActionsPreview/types.ts | 7 -- .../getReportActionsListInitialNumToRender.ts | 29 ------ ...ReportActionsListInitialNumToRenderTest.ts | 44 --------- 7 files changed, 3 insertions(+), 206 deletions(-) delete mode 100644 src/pages/inbox/report/StaticReportActionsPreview/index.native.tsx delete mode 100644 src/pages/inbox/report/StaticReportActionsPreview/index.tsx delete mode 100644 src/pages/inbox/report/StaticReportActionsPreview/types.ts delete mode 100644 src/pages/inbox/report/getReportActionsListInitialNumToRender.ts delete mode 100644 tests/unit/getReportActionsListInitialNumToRenderTest.ts diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 7062af6b5cc5..3aa962d48aa1 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -20,9 +20,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {isSafari} from '@libs/Browser'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; @@ -64,7 +62,6 @@ import Visibility from '@libs/Visibility'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; -import variables from '@styles/variables'; import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -72,13 +69,10 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import FloatingMessageCounter from './FloatingMessageCounter'; -import getInitialNumToRender from './getInitialNumReportActionsToRender'; -import getReportActionsListInitialNumToRender from './getReportActionsListInitialNumToRender'; import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from './shouldDisplayNewMarkerOnReportAction'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; -import StaticReportActionsPreview from './StaticReportActionsPreview'; import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; type ReportActionsListProps = { @@ -118,9 +112,6 @@ type ReportActionsListProps = { /** ID of the list */ listID: number; - /** Whether the optimistic CREATED report action was added */ - hasCreatedActionAdded?: boolean; - /** Whether the chat history is hidden (concierge side panel fresh state) */ showHiddenHistory?: boolean; @@ -163,18 +154,14 @@ function ReportActionsList({ isComposerFullSize, listID, parentReportActionForTransactionThread, - hasCreatedActionAdded, showHiddenHistory, hasPreviousMessages, onShowPreviousMessages, }: ReportActionsListProps) { - const prevHasCreatedActionAdded = usePrevious(hasCreatedActionAdded); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const personalDetailsList = usePersonalDetails(); const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {getLocalDateFromDatetime} = useLocalize(); @@ -232,9 +219,6 @@ function ReportActionsList({ const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); - const topReportAction = renderedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); - const scrollEndTimerRef = useRef | undefined>(undefined); const isAnonymousUser = useIsAnonymousUser(); useEffect(() => { @@ -392,20 +376,10 @@ function ReportActionsList({ scrollOffsetRef.current = offset; setShouldMaintainVisibleContentPosition(offset > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); onScroll?.(event); - // We use a timeout to wait for the scroll to finish before resetting the flag. - // onMomentumScrollEnd would be ideal but it doesn't work on web. - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline) && !scrollEndTimerRef.current) { - scrollEndTimerRef.current = setTimeout(() => { - setShouldScrollToEndAfterLayout(false); - scrollEndTimerRef.current = undefined; - }, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME); - } }, hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, }); - useEffect(() => () => clearTimeout(scrollEndTimerRef.current), []); - useScrollToEndOnNewMessageReceived({ sizeChangeType: 'changed', scrollOffsetRef, @@ -432,14 +406,6 @@ function ReportActionsList({ }); }, [draftAutoScrollKey, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); - useEffect(() => { - const shouldTriggerScroll = shouldFocusToTopOnMount && prevHasCreatedActionAdded && !hasCreatedActionAdded; - if (!shouldTriggerScroll) { - return; - } - requestAnimationFrame(() => reportScrollManager.scrollToEnd()); - }, [hasCreatedActionAdded, prevHasCreatedActionAdded, shouldFocusToTopOnMount, shouldScrollToEndAfterLayout, reportScrollManager]); - useEffect(() => { userActiveSince.current = DateUtils.getDBTime(); prevReportID = report.reportID; @@ -532,7 +498,7 @@ function ReportActionsList({ } InteractionManager.runAfterInteractions(() => { - if (shouldScrollToEndAfterLayout) { + if (shouldFocusToTopOnMount) { return; } setIsFloatingMessageCounterVisible(false); @@ -673,34 +639,6 @@ function ReportActionsList({ readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID, backTo, introSelected, reportLoadingState?.hasOnceLoadedReportActions, betas]); - /** - * Calculates the ideal number of report actions to render in the first render, based on the screen height and on - * the height of the smallest report action possible. - */ - const initialNumToRender = useMemo((): number | undefined => { - const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; - const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); - const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); - return getReportActionsListInitialNumToRender({ - numToRender, - linkedReportActionID, - shouldScrollToEndAfterLayout, - hasCreatedActionAdded, - sortedVisibleReportActionsLength: renderedVisibleReportActions.length, - isOffline, - getInitialNumToRender, - }); - }, [ - styles.chatItem.paddingBottom, - styles.chatItem.paddingTop, - windowHeight, - linkedReportActionID, - shouldScrollToEndAfterLayout, - hasCreatedActionAdded, - renderedVisibleReportActions.length, - isOffline, - ]); - /** * Thread's divider line should hide when the first chat in the thread is marked as unread. * This is so that it will not be conflicting with header's separator line. @@ -829,13 +767,8 @@ function ReportActionsList({ reportScrollManager.scrollToBottom(); setIsScrollToBottomEnabled(false); } - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { - requestAnimationFrame(() => { - reportScrollManager.scrollToEnd(); - }); - } }, - [isOffline, isScrollToBottomEnabled, onLayout, reportScrollManager, hasCreatedActionAdded, shouldScrollToEndAfterLayout], + [isScrollToBottomEnabled, onLayout, reportScrollManager], ); const retryLoadNewerChatsError = useCallback(() => { @@ -868,26 +801,6 @@ function ReportActionsList({ return ; }, [shouldShowSkeleton]); - const renderTopReportActions = useCallback(() => { - const previewItems = renderedVisibleReportActions.slice(initialNumToRender ? -initialNumToRender : 0).reverse(); - - return ( - <> - {!shouldShowReportRecipientLocalTime && !hideComposer && } - - {previewItems.map((action) => ( - - {renderItem({ - item: action, - index: actionIndexMap.get(action.reportActionID) ?? 0, - } as ListRenderItemInfo)} - - ))} - - - ); - }, [actionIndexMap, hideComposer, initialNumToRender, renderItem, shouldShowReportRecipientLocalTime, renderedVisibleReportActions, styles]); - const onStartReached = useCallback(() => { if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); @@ -912,7 +825,6 @@ function ReportActionsList({ style={[styles.flex1, !shouldShowReportRecipientLocalTime && !hideComposer ? styles.pb4 : {}]} fsClass={reportActionsListFSClass} > - {shouldScrollToEndAfterLayout && topReportAction ? renderTopReportActions() : undefined} - {children} - - ); -} - -export default StaticReportActionsPreview; diff --git a/src/pages/inbox/report/StaticReportActionsPreview/index.tsx b/src/pages/inbox/report/StaticReportActionsPreview/index.tsx deleted file mode 100644 index 0e7a10b928d0..000000000000 --- a/src/pages/inbox/report/StaticReportActionsPreview/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import ScrollView from '@components/ScrollView'; -import useThemeStyles from '@hooks/useThemeStyles'; -import type StaticReportActionsPreviewProps from './types'; - -function StaticReportActionsPreview({children}: StaticReportActionsPreviewProps) { - const styles = useThemeStyles(); - - return {children}; -} - -export default StaticReportActionsPreview; diff --git a/src/pages/inbox/report/StaticReportActionsPreview/types.ts b/src/pages/inbox/report/StaticReportActionsPreview/types.ts deleted file mode 100644 index 3623432d0837..000000000000 --- a/src/pages/inbox/report/StaticReportActionsPreview/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {ReactNode} from 'react'; - -type StaticReportActionsPreviewProps = { - children: ReactNode; -}; - -export default StaticReportActionsPreviewProps; diff --git a/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts b/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts deleted file mode 100644 index a204ddb14faa..000000000000 --- a/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts +++ /dev/null @@ -1,29 +0,0 @@ -type GetReportActionsListInitialNumToRenderParams = { - numToRender: number; - linkedReportActionID?: string; - shouldScrollToEndAfterLayout: boolean; - hasCreatedActionAdded?: boolean; - sortedVisibleReportActionsLength: number; - isOffline: boolean; - getInitialNumToRender: (numToRender: number) => number; -}; - -export default function getReportActionsListInitialNumToRender({ - numToRender, - linkedReportActionID, - shouldScrollToEndAfterLayout, - hasCreatedActionAdded, - sortedVisibleReportActionsLength, - isOffline, - getInitialNumToRender, -}: GetReportActionsListInitialNumToRenderParams): number | undefined { - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { - return sortedVisibleReportActionsLength; - } - - if (linkedReportActionID) { - return getInitialNumToRender(numToRender); - } - - return numToRender || undefined; -} diff --git a/tests/unit/getReportActionsListInitialNumToRenderTest.ts b/tests/unit/getReportActionsListInitialNumToRenderTest.ts deleted file mode 100644 index 02afc164272c..000000000000 --- a/tests/unit/getReportActionsListInitialNumToRenderTest.ts +++ /dev/null @@ -1,44 +0,0 @@ -import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; -import getReportActionsListInitialNumToRender from '@pages/inbox/report/getReportActionsListInitialNumToRender'; - -describe('getReportActionsListInitialNumToRender', () => { - it('returns the full list length when scroll-to-end mode is enabled before the created action is added', () => { - const result = getReportActionsListInitialNumToRender({ - numToRender: 12, - shouldScrollToEndAfterLayout: true, - hasCreatedActionAdded: false, - sortedVisibleReportActionsLength: 500, - isOffline: false, - getInitialNumToRender, - }); - - expect(result).toBe(500); - }); - - it('returns the platform-adjusted value for linked report actions', () => { - const result = getReportActionsListInitialNumToRender({ - numToRender: 10, - linkedReportActionID: '123', - shouldScrollToEndAfterLayout: false, - hasCreatedActionAdded: true, - sortedVisibleReportActionsLength: 500, - isOffline: false, - getInitialNumToRender, - }); - - expect(result).toBe(getInitialNumToRender(10)); - }); - - it('returns numToRender when there is no linked report action and the scroll-to-end short-circuit does not apply', () => { - const result = getReportActionsListInitialNumToRender({ - numToRender: 10, - shouldScrollToEndAfterLayout: false, - hasCreatedActionAdded: true, - sortedVisibleReportActionsLength: 3, - isOffline: false, - getInitialNumToRender, - }); - - expect(result).toBe(10); - }); -}); From 1c67e2346ab47fda982c0554fb2f4df410359cc2 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 8 May 2026 10:43:13 +0200 Subject: [PATCH 02/12] Fix with initialScrollIndex --- src/pages/inbox/report/ReportActionsList.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 3aa962d48aa1..017f07afca09 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -21,6 +21,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {isSafari} from '@libs/Browser'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; @@ -138,6 +139,9 @@ let prevReportID: string | null = null; * random enough to avoid collisions */ function keyExtractor(item: OnyxTypes.ReportAction): string { + if (item.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return CONST.REPORT.ACTIONS.TYPE.CREATED; + } return item.reportActionID; } @@ -162,6 +166,7 @@ function ReportActionsList({ const personalDetailsList = usePersonalDetails(); const styles = useThemeStyles(); const {translate} = useLocalize(); + const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {getLocalDateFromDatetime} = useLocalize(); @@ -233,7 +238,8 @@ function ReportActionsList({ const hasHeaderRendered = useRef(false); const lastAction = sortedVisibleReportActions.at(0); - const [shouldMaintainVisibleContentPosition, setShouldMaintainVisibleContentPosition] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const [hasScrolledOverTreshold, setHasScrolledOverTreshold] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const shouldMaintainVisibleContentPosition = hasScrolledOverTreshold || shouldFocusToTopOnMount; const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( () => sortedVisibleReportActions.reduce((actions, action) => { @@ -374,7 +380,7 @@ function ReportActionsList({ onTrackScrolling: (event: NativeSyntheticEvent) => { const offset = event.nativeEvent.contentOffset.y; scrollOffsetRef.current = offset; - setShouldMaintainVisibleContentPosition(offset > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + setHasScrolledOverTreshold(offset > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); onScroll?.(event); }, hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, @@ -848,9 +854,14 @@ function ReportActionsList({ onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} key={listID} - overrideProps={{isInvertedVirtualizedList: true}} + overrideProps={{ + isInvertedVirtualizedList: true, + contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, + }} getItemType={(item) => item.actionName} shouldMaintainVisibleContentPosition={shouldMaintainVisibleContentPosition} + initialScrollIndex={shouldFocusToTopOnMount ? renderedVisibleReportActions.length - 1 : undefined} + initialScrollIndexParams={shouldFocusToTopOnMount ? {viewOffset: windowHeight} : undefined} initialScrollKey={linkedReportActionID} onContentSizeChange={() => { trackVerticalScrolling(undefined); From 770bb46a326af96e46ff53c6e1d4667f976632bb Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 11 May 2026 18:34:45 +0200 Subject: [PATCH 03/12] Fix jump on the initial data load --- src/pages/inbox/report/ReportActionsList.tsx | 18 ++++++++++++++++-- src/pages/inbox/report/ReportActionsView.tsx | 4 +--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 017f07afca09..08913d3d3704 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -371,6 +371,18 @@ function ReportActionsList({ hasNewestReportActionRef.current = hasNewestReportAction; const sortedVisibleReportActionsRef = useRef(sortedVisibleReportActions); + const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; + const prevHasOnceLoadedReportActions = usePrevious(hasOnceLoadedReportActions); + const [shouldDisplayCreatedActionOnly, setShouldDisplayCreatedActionOnly] = useState(shouldFocusToTopOnMount && !hasOnceLoadedReportActions); + + // Defer hiding the created-action-only view until the next frame so the full list in place, preventing a visual jump when report actions finish loading + useEffect(() => { + if (!shouldDisplayCreatedActionOnly || prevHasOnceLoadedReportActions || !hasOnceLoadedReportActions) { + return; + } + requestAnimationFrame(() => setShouldDisplayCreatedActionOnly(false)); + }, [hasOnceLoadedReportActions, prevHasOnceLoadedReportActions, shouldDisplayCreatedActionOnly]); + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, currentVerticalScrollingOffsetRef: scrollOffsetRef, @@ -820,6 +832,8 @@ function ReportActionsList({ loadOlderChats(false); }, [loadOlderChats]); + const data = shouldDisplayCreatedActionOnly ? renderedVisibleReportActions.slice(renderedVisibleReportActions.length - 1) : renderedVisibleReportActions; + return ( <> item.actionName} shouldMaintainVisibleContentPosition={shouldMaintainVisibleContentPosition} - initialScrollIndex={shouldFocusToTopOnMount ? renderedVisibleReportActions.length - 1 : undefined} + initialScrollIndex={shouldFocusToTopOnMount ? data.length - 1 : undefined} initialScrollIndexParams={shouldFocusToTopOnMount ? {viewOffset: windowHeight} : undefined} initialScrollKey={linkedReportActionID} onContentSizeChange={() => { diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 13dec5e88b0e..aab13ad68d3c 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -148,9 +148,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { ); const lastAction = allReportActions?.at(-1); - const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); - - const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isInitiallyLoadingTransactionThread || isConciergeSidePanel); + const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isReportTransactionThread || isConciergeSidePanel); useEffect(() => { // When we linked to message - we do not need to wait for initial actions - they already exists From 952bebc393253454728a1c5316e55f626c1925ad Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 11 May 2026 19:30:56 +0200 Subject: [PATCH 04/12] Patch flash-list: bump applyInitialScrollIndex pause to 500ms --- ...pify+flash-list+2.3.0+008+increase-timeout.patch | 13 +++++++++++++ patches/@shopify/flash-list/details.md | 8 ++++++++ 2 files changed, 21 insertions(+) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch new file mode 100644 index 000000000000..a76bba73cc2d --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +index 51b6f8c..d4ca252 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +@@ -507,7 +507,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + setTimeout(() => { + recyclerViewManager.isInitialScrollComplete = true; + pauseOffsetCorrection.current = false; +- }, 100); ++ }, 500); + pauseOffsetCorrection.current = true; + const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; + const offset = horizontal diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index e9a2002a9426..dd145f525594 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -56,3 +56,11 @@ - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch) + +- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms. +- Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/89768 +- PR introducing patch: https://github.com/Expensify/App/pull/90218 From 6f7ef2f844a9f3f1e992d41306d59b864421f6d9 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 11 May 2026 19:31:46 +0200 Subject: [PATCH 05/12] Spell fix --- src/pages/inbox/report/ReportActionsList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 08913d3d3704..bcf2e96cc341 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -238,8 +238,8 @@ function ReportActionsList({ const hasHeaderRendered = useRef(false); const lastAction = sortedVisibleReportActions.at(0); - const [hasScrolledOverTreshold, setHasScrolledOverTreshold] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const shouldMaintainVisibleContentPosition = hasScrolledOverTreshold || shouldFocusToTopOnMount; + const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( () => sortedVisibleReportActions.reduce((actions, action) => { @@ -392,7 +392,7 @@ function ReportActionsList({ onTrackScrolling: (event: NativeSyntheticEvent) => { const offset = event.nativeEvent.contentOffset.y; scrollOffsetRef.current = offset; - setHasScrolledOverTreshold(offset > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + setHasScrolledOverThreshold(offset > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); onScroll?.(event); }, hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, From b6c2319e1c34c63ce158501813d259f931c14b0f Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 12 May 2026 10:13:39 +0200 Subject: [PATCH 06/12] Clean up after conflicts resolution --- src/pages/inbox/report/ReportActionsList.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 3d24b8f56152..0db1d21f5765 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -22,7 +22,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {openReport, readNewestAction} from '@libs/actions/Report'; import {isSafari} from '@libs/Browser'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; @@ -63,8 +62,7 @@ import Visibility from '@libs/Visibility'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; -import variables from '@styles/variables'; -import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; +import {openReport, readNewestAction} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -228,7 +226,6 @@ function ReportActionsList({ const hasHeaderRendered = useRef(false); const lastAction = sortedVisibleReportActions.at(0); - const [shouldMaintainVisibleContentPosition, setShouldMaintainVisibleContentPosition] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( () => sortedVisibleReportActions.reduce((actions, action) => { @@ -347,10 +344,6 @@ function ReportActionsList({ const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; - const topReportAction = renderedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !initialScrollKey); - const scrollEndTimerRef = useRef | undefined>(undefined); - /** * The timestamp for the unread marker. * @@ -824,13 +817,7 @@ function ReportActionsList({ completeLiveTailPruneAfterScrollToBottom(); } }, - [ - isScrollToBottomEnabled, - onLayout, - reportScrollManager, - completeLiveTailPruneAfterScrollToBottom, - setIsScrollToBottomEnabled, - ], + [isScrollToBottomEnabled, onLayout, reportScrollManager, completeLiveTailPruneAfterScrollToBottom, setIsScrollToBottomEnabled], ); const retryLoadNewerChatsError = useCallback(() => { From 8384aeaab823f4606b723676f1bcbd568018a2ba Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 12 May 2026 15:43:27 +0200 Subject: [PATCH 07/12] Replace display approach to fix issue with messages display offline --- src/pages/inbox/report/ReportActionsList.tsx | 21 +++++--------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 0db1d21f5765..e9e061dc57e3 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -423,18 +423,6 @@ function ReportActionsList({ const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); - const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; - const prevHasOnceLoadedReportActions = usePrevious(hasOnceLoadedReportActions); - const [shouldDisplayCreatedActionOnly, setShouldDisplayCreatedActionOnly] = useState(shouldFocusToTopOnMount && !hasOnceLoadedReportActions); - - // Defer hiding the created-action-only view until the next frame so the full list in place, preventing a visual jump when report actions finish loading - useEffect(() => { - if (!shouldDisplayCreatedActionOnly || prevHasOnceLoadedReportActions || !hasOnceLoadedReportActions) { - return; - } - requestAnimationFrame(() => setShouldDisplayCreatedActionOnly(false)); - }, [hasOnceLoadedReportActions, prevHasOnceLoadedReportActions, shouldDisplayCreatedActionOnly]); - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, currentVerticalScrollingOffsetRef: scrollOffsetRef, @@ -863,8 +851,6 @@ function ReportActionsList({ loadOlderChats(false); }, [loadOlderChats]); - const data = shouldDisplayCreatedActionOnly ? renderedVisibleReportActions.slice(renderedVisibleReportActions.length - 1) : renderedVisibleReportActions; - return ( <> item.actionName} shouldMaintainVisibleContentPosition={shouldMaintainVisibleContentPosition} - initialScrollIndex={shouldFocusToTopOnMount ? data.length - 1 : undefined} + initialScrollIndex={shouldFocusToTopOnMount ? renderedVisibleReportActions.length - 1 : undefined} initialScrollIndexParams={shouldFocusToTopOnMount ? {viewOffset: windowHeight} : undefined} + maintainVisibleContentPosition={ + shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, animateAutoScrollToBottom: false} : undefined + } initialScrollKey={initialScrollKey} onContentSizeChange={() => { trackVerticalScrolling(undefined); From 8fadbc05da2535bfdb962ad0d813a992b5a6dc77 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 12 May 2026 16:08:18 +0200 Subject: [PATCH 08/12] Fix auto-scroll logic to escape jumps for new messages --- src/pages/inbox/report/ReportActionsList.tsx | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e9e061dc57e3..517fb024d2c1 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -315,6 +315,7 @@ function ReportActionsList({ const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); + const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); const renderedVisibleReportActions = useMemo(() => { if (!draftReportAction) { return sortedVisibleReportActions; @@ -851,6 +852,31 @@ function ReportActionsList({ loadOlderChats(false); }, [loadOlderChats]); + // Data is ready at the moment FlashList finishes its first render. + // Wait one frame so the initial autoscroll-to-top can settle, then disable it. + const onLoad = () => { + if (!shouldFocusToTopOnMount) { + return; + } + if (!reportLoadingState?.hasOnceLoadedReportActions && !isOffline) { + return; + } + requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); + }; + const prevHasOnceLoadedReportActions = usePrevious(reportLoadingState?.hasOnceLoadedReportActions); + + // Data finished initial loading after the list mounted. onLoad has already fired, so we need + // a separate trigger to turn off autoscroll-to-top. + useEffect(() => { + if (!shouldFocusToTopOnMount || !shouldAutoscrollToBottom) { + return; + } + if (prevHasOnceLoadedReportActions || !reportLoadingState?.hasOnceLoadedReportActions) { + return; + } + requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); + }, [shouldFocusToTopOnMount, shouldAutoscrollToBottom, prevHasOnceLoadedReportActions, reportLoadingState?.hasOnceLoadedReportActions]); + return ( <> { trackVerticalScrolling(undefined); From 1faa6268acb708df46c7899837f2adb09ae8f89b Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 12 May 2026 16:40:20 +0200 Subject: [PATCH 09/12] Fix bug --- src/pages/inbox/report/ReportActionsList.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 517fb024d2c1..5f4ace7bce51 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -314,7 +314,8 @@ function ReportActionsList({ const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); - const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); + const shouldBeAlignedToTop = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); + const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey; const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); const renderedVisibleReportActions = useMemo(() => { if (!draftReportAction) { @@ -898,7 +899,7 @@ function ReportActionsList({ keyExtractor={keyExtractor} drawDistance={1500} renderScrollComponent={renderActionSheetAwareScrollView} - contentContainerStyle={[styles.chatContentScrollView, shouldFocusToTopOnMount && styles.justifyContentEnd]} + contentContainerStyle={[styles.chatContentScrollView, shouldBeAlignedToTop && styles.justifyContentEnd]} onEndReached={onEndReached} onEndReachedThreshold={0.75} onStartReached={handleStartReached} From 27592a9c906640575a56a9e8221929d11334d87a Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 12 May 2026 16:57:28 +0200 Subject: [PATCH 10/12] Add comment --- src/pages/inbox/report/ReportActionsList.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 5f4ace7bce51..fc4713758ce9 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -150,6 +150,8 @@ let prevReportID: string | null = null; * random enough to avoid collisions */ function keyExtractor(item: OnyxTypes.ReportAction): string { + // A report has exactly one CREATED action. Using a stable key lets FlashList recycle the same cell + // when the optimistic CREATED is swapped for the server one, avoiding a remount-induced scroll jump. if (item.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { return CONST.REPORT.ACTIONS.TYPE.CREATED; } From 5180b7bfdbc21368c10e2fe4cd8c3bb214a14214 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 18 May 2026 10:31:43 +0200 Subject: [PATCH 11/12] Re-apply changes after merging main --- src/hooks/useReportActionsPagination.ts | 8 +------- src/pages/inbox/report/ReportActionsView.tsx | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/hooks/useReportActionsPagination.ts b/src/hooks/useReportActionsPagination.ts index b58e75906e56..1cf2d919a15e 100644 --- a/src/hooks/useReportActionsPagination.ts +++ b/src/hooks/useReportActionsPagination.ts @@ -25,7 +25,6 @@ type UseReportActionsPaginationResult = { transactionThreadReportID: string | undefined; transactionThreadReport: OnyxEntry; parentReportActionForTransactionThread: ReportAction | undefined; - shouldAddCreatedAction: boolean; treatAsNoPaginationAnchor: boolean; setTreatAsNoPaginationAnchor: (value: boolean) => void; }; @@ -56,14 +55,10 @@ function useReportActionsPagination(reportID: string | undefined, reportActionID const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); - const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); - const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; - const isReportTransactionThread = isReportTransactionThreadUtil(report); - const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); const lastAction = allReportActions?.at(-1); - const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isInitiallyLoadingTransactionThread || isConciergeSidePanel); + const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isReportTransactionThread || isConciergeSidePanel); const reportPreviewAction = useMemo(() => getReportPreviewAction(report?.chatReportID, report?.reportID), [report?.chatReportID, report?.reportID]); @@ -96,7 +91,6 @@ function useReportActionsPagination(reportID: string | undefined, reportActionID transactionThreadReportID: thread.transactionThreadReportID, transactionThreadReport: thread.transactionThreadReport, parentReportActionForTransactionThread: thread.parentReportActionForTransactionThread, - shouldAddCreatedAction, treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor, }; diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 4f32d19f2ad1..5c6a50a56f4d 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -61,7 +61,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { transactionThreadReportID, transactionThreadReport, parentReportActionForTransactionThread, - shouldAddCreatedAction, treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor, } = useReportActionsPagination(reportID, reportActionIDFromRoute); From 7981b0fba1b02b24f769b3ce746e2ea873175dd4 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Mon, 18 May 2026 11:54:30 +0200 Subject: [PATCH 12/12] Fix overlap of initialScrollKey/shouldBeAlignedToTop logic --- src/pages/inbox/report/ReportActionsList.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index d6698f3ef13a..0a0f6198979e 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -308,13 +308,21 @@ function ReportActionsList({ ]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; - const initialScrollKey = useMemo(() => { - return linkedReportActionID ?? unreadMarkerReportActionID ?? undefined; - }, [linkedReportActionID, unreadMarkerReportActionID]); - const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); const shouldBeAlignedToTop = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); + const initialScrollKey = useMemo(() => { + const actionID = linkedReportActionID ?? unreadMarkerReportActionID; + if (!actionID) { + return; + } + + // The correct scroll behavior in this case will be handled by shouldFocusToTopOnMount logic + if (shouldBeAlignedToTop && sortedVisibleReportActionsObjects[actionID]?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return; + } + return actionID; + }, [linkedReportActionID, unreadMarkerReportActionID, shouldBeAlignedToTop, sortedVisibleReportActionsObjects]); const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey; const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); const renderedVisibleReportActions = useMemo(() => {