From 4a9ef9f08efe306f53c1e3c4b99c26e942e647f1 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 23 Oct 2024 17:39:11 -0400 Subject: [PATCH 001/216] Open reports at first unread action --- __mocks__/react-native.ts | 2 +- src/CONST.ts | 3 +- .../getInitialPaginationSize/index.native.ts | 3 - .../getInitialPaginationSize/index.ts | 3 - .../BaseInvertedFlatList/index.tsx | 23 +++- src/libs/API/parameters/OpenReportParams.ts | 1 + src/libs/Middleware/Pagination.ts | 10 +- src/libs/actions/Report.ts | 1 + src/pages/home/report/ReportActionsList.tsx | 33 +++++- src/pages/home/report/ReportActionsView.tsx | 4 +- tests/ui/PaginationTest.tsx | 81 +++---------- tests/ui/UnreadIndicatorsTest.tsx | 112 +++++++++--------- tests/utils/ReportTestUtils.ts | 70 ++++++++++- 13 files changed, 204 insertions(+), 142 deletions(-) delete mode 100644 src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.native.ts delete mode 100644 src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.ts diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 4c2a86818e9b..22aacc12d8db 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -45,7 +45,7 @@ jest.doMock('react-native', () => { NativeModules: { ...ReactNative.NativeModules, BootSplash: { - hide: jest.fn(), + hide: jest.fn().mockResolvedValue(undefined), logoSizeRatio: 1, navigationBarHeight: 0, }, diff --git a/src/CONST.ts b/src/CONST.ts index 622db1610ccd..4e96102a980d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5369,8 +5369,7 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', - MOBILE_PAGINATION_SIZE: 15, - WEB_PAGINATION_SIZE: 30, + PAGINATION_SIZE: 15, /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.native.ts b/src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.native.ts deleted file mode 100644 index 195448f7e450..000000000000 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.MOBILE_PAGINATION_SIZE; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.ts b/src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.ts deleted file mode 100644 index 87ec6856aa20..000000000000 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.WEB_PAGINATION_SIZE; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index cd8eeb187a08..b9b477e2ad41 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -3,7 +3,7 @@ import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; import usePrevious from '@hooks/usePrevious'; -import getInitialPaginationSize from './getInitialPaginationSize'; +import CONST from '@src/CONST'; import RenderTaskQueue from './RenderTaskQueue'; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 @@ -46,7 +46,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa if (currentDataIndex <= 0) { return data; } - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : getInitialPaginationSize))); + return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE))); }, [currentDataIndex, data, isInitialData]); const isLoadingData = data.length > displayedData.length; @@ -111,6 +111,22 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa }); }; + const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => { + const actualIndex = params.index - dataIndexDifference; + try { + listRef.current?.scrollToIndex({...params, index: actualIndex}); + } catch (ex) { + // It is possible that scrolling fails since the item we are trying to scroll to + // has not been rendered yet. In this case, we call the onScrollToIndexFailed. + props.onScrollToIndexFailed?.({ + index: actualIndex, + // These metrics are not implemented. + averageItemLength: 0, + highestMeasuredFrameIndex: 0, + }); + } + }; + return new Proxy( {}, { @@ -118,6 +134,9 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa if (prop === 'scrollToOffset') { return scrollToOffsetFn; } + if (prop === 'scrollToIndex') { + return scrollToIndexFn; + } return listRef.current?.[prop as keyof RNFlatList]; }, }, diff --git a/src/libs/API/parameters/OpenReportParams.ts b/src/libs/API/parameters/OpenReportParams.ts index a665313580e8..7c57760f9576 100644 --- a/src/libs/API/parameters/OpenReportParams.ts +++ b/src/libs/API/parameters/OpenReportParams.ts @@ -15,6 +15,7 @@ type OpenReportParams = { optimisticAccountIDList?: string; file?: File | CustomRNImageManipulatorResult; guidedSetupData?: string; + useLastUnreadReportAction?: boolean; }; export default OpenReportParams; diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 251609d1254c..a34165e8d783 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -105,7 +105,7 @@ const Pagination: Middleware = (requestResponse, request) => { const newPage = sortedPageItems.map((item) => getItemID(item)); - if (response.hasNewerActions === false || (type === 'initial' && !cursorID)) { + if (response.hasNewerActions === false) { newPage.unshift(CONST.PAGINATION_START_ID); } if (response.hasOlderActions === false) { @@ -119,8 +119,14 @@ const Pagination: Middleware = (requestResponse, request) => { const pagesCollections = pages.get(pageCollectionKey) ?? {}; const existingPages = pagesCollections[pageKey] ?? []; - const mergedPages = PaginationUtils.mergeAndSortContinuousPages(sortedAllItems, [...existingPages, newPage], getItemID); + // When loading the first page of data, make sure to remove the start maker if the backend returns + // that there is new data. + const firstPage = existingPages.at(0); + if (type === 'initial' && !cursorID && firstPage?.at(0) === CONST.PAGINATION_START_ID && response.hasNewerActions === true) { + firstPage.shift(); + } + const mergedPages = PaginationUtils.mergeAndSortContinuousPages(sortedAllItems, [...existingPages, newPage], getItemID); response.onyxData.push({ key: pageKey, onyxMethod: Onyx.METHOD.SET, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d11663228f40..3462541b6775 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -917,6 +917,7 @@ function openReport( emailList: participantLoginList ? participantLoginList.join(',') : '', accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '', parentReportActionID, + useLastUnreadReportAction: true, }; const isInviteOnboardingComplete = introSelected?.isInviteOnboardingComplete ?? false; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 989781c6373b..0979f04e3498 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -275,7 +275,7 @@ function ReportActionsList({ /** * The reportActionID the unread marker should display above */ - const unreadMarkerReportActionID = useMemo(() => { + const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useMemo(() => { const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { const nextMessage = sortedVisibleReportActions.at(index + 1); const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime); @@ -332,11 +332,11 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler if (reportAction && shouldDisplayNewMarker(reportAction, index)) { - return reportAction.reportActionID; + return {unreadMarkerReportActionID: reportAction.reportActionID, unreadMarkerReportActionIndex: index}; } } - return null; + return {unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}; }, [accountID, earliestReceivedOfflineMessageIndex, prevSortedVisibleReportActionsObjects, sortedVisibleReportActions, unreadMarkerTime]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; @@ -446,7 +446,7 @@ function ReportActionsList({ }, [report.lastVisibleActionCreated, report.reportID, isVisible]); useEffect(() => { - if (linkedReportActionID) { + if (linkedReportActionID || unreadMarkerReportActionID) { return; } InteractionManager.runAfterInteractions(() => { @@ -491,7 +491,7 @@ function ReportActionsList({ useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? // Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted, - // meaning that the cleanup might not get called. When we then open a report we had open already previosuly, a new + // meaning that the cleanup might not get called. When we then open a report we had open already previously, a new // ReportScreen will get created. Thus, we have to cancel the earlier subscription of the previous screen, // because the two subscriptions could conflict! // In case we return to the previous screen (e.g. by web back navigation) the useEffect for that screen would @@ -639,6 +639,27 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isFocused, isVisible]); + // Handles scrolling to the unread marker. + // If we have an unread marker initially we do not need to scroll to it as this + // will be handled by the list `initialScrollKey`. + const didScrollToUnreadMarker = useRef(unreadMarkerReportActionIndex >= 0); + useEffect(() => { + if (unreadMarkerReportActionIndex === -1 || didScrollToUnreadMarker.current) { + return; + } + didScrollToUnreadMarker.current = true; + InteractionManager.runAfterInteractions(() => { + reportScrollManager.ref?.current?.scrollToIndex({ + index: unreadMarkerReportActionIndex, + animated: true, + // This scrolls the unread action at the top of the screen. + viewPosition: 1, + // This makes sure that the unread indicator doesn't get cut off. + viewOffset: -64, + }); + }); + }, [reportScrollManager, unreadMarkerReportActionIndex]); + const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => ( diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index e5e715c16d86..5f70ba49cab1 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -330,8 +330,7 @@ function ReportActionsView({ (force = false) => { if ( !force && - (!reportActionID || - !isFocused || + (!isFocused || !newestReportAction || isLoadingInitialReportActions || isLoadingNewerReportActions || @@ -361,7 +360,6 @@ function ReportActionsView({ } }, [ - reportActionID, isFocused, newestReportAction, isLoadingInitialReportActions, diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 03f32f8ca846..9792f9b29b31 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -4,16 +4,17 @@ import {act, fireEvent, render, screen, within} from '@testing-library/react-nat import {addSeconds, format, subMinutes} from 'date-fns'; import React from 'react'; import Onyx from 'react-native-onyx'; -import * as Localize from '@libs/Localize'; -import * as SequentialQueue from '@libs/Network/SequentialQueue'; -import * as AppActions from '@userActions/App'; -import * as User from '@userActions/User'; +import {setSidebarLoaded} from '@libs/actions/App'; +import {subscribeToUserEvents} from '@libs/actions/User'; +import {translateLocal} from '@libs/Localize'; +import {waitForIdle} from '@libs/Network/SequentialQueue'; import App from '@src/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; import PusherHelper from '../utils/PusherHelper'; +import {getReportScreen, LIST_CONTENT_SIZE, navigateToSidebarOption, REPORT_ID, scrollToOffset, triggerListLayout} from '../utils/ReportTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -30,75 +31,23 @@ jest.mock('@src/components/Navigation/TopLevelBottomTabBar/useIsBottomTabVisible TestHelper.setupApp(); const fetchMock = TestHelper.setupGlobalFetchMock(); -const LIST_SIZE = { - width: 300, - height: 400, -}; -const LIST_CONTENT_SIZE = { - width: 300, - height: 600, -}; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); -const REPORT_ID = '1'; const COMMENT_LINKING_REPORT_ID = '2'; const USER_A_ACCOUNT_ID = 1; const USER_A_EMAIL = 'user_a@test.com'; const USER_B_ACCOUNT_ID = 2; const USER_B_EMAIL = 'user_b@test.com'; -function getReportScreen(reportID = REPORT_ID) { - return screen.getByTestId(`report-screen-${reportID}`); -} - -function scrollToOffset(offset: number) { - const hintText = Localize.translateLocal('sidebarScreen.listOfChatMessages'); - fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { - nativeEvent: { - contentOffset: { - y: offset, - }, - contentSize: LIST_CONTENT_SIZE, - layoutMeasurement: LIST_SIZE, - }, - }); -} - -function triggerListLayout(reportID?: string) { - const report = getReportScreen(reportID); - fireEvent(within(report).getByTestId('report-actions-view-wrapper'), 'onLayout', { - nativeEvent: { - layout: { - x: 0, - y: 0, - ...LIST_SIZE, - }, - }, - }); - - fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); -} - function getReportActions(reportID?: string) { const report = getReportScreen(reportID); return [ - ...within(report).queryAllByLabelText(Localize.translateLocal('accessibilityHints.chatMessage')), + ...within(report).queryAllByLabelText(translateLocal('accessibilityHints.chatMessage')), // Created action has a different accessibility label. - ...within(report).queryAllByLabelText(Localize.translateLocal('accessibilityHints.chatWelcomeMessage')), + ...within(report).queryAllByLabelText(translateLocal('accessibilityHints.chatWelcomeMessage')), ]; } -async function navigateToSidebarOption(reportID: string): Promise { - const optionRow = screen.getByTestId(reportID); - fireEvent(optionRow, 'press'); - await act(() => { - (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); - }); - // ReportScreen relies on the onLayout event to receive updates from onyx. - triggerListLayout(reportID); - await waitForBatchedUpdatesWithAct(); -} - function buildCreatedAction(reportActionID: string, created: string) { return { reportActionID, @@ -129,7 +78,7 @@ function buildReportComments(count: number, initialID: string, reverse = false) } function mockOpenReport(messageCount: number, initialID: string) { - fetchMock.mockAPICommand('OpenReport', ({reportID}) => { + fetchMock.mockAPICommand('OpenReport', ({reportID, reportActionID}) => { const comments = buildReportComments(messageCount, initialID); return { onyxData: @@ -143,7 +92,7 @@ function mockOpenReport(messageCount: number, initialID: string) { ] : [], hasOlderActions: !comments['1'], - hasNewerActions: !!reportID, + hasNewerActions: !!reportActionID, }; }); } @@ -192,7 +141,7 @@ async function signInAndGetApp(): Promise { // Render the App and sign in as a test user. render(); await waitForBatchedUpdatesWithAct(); - const hintText = Localize.translateLocal('loginForm.loginForm'); + const hintText = translateLocal('loginForm.loginForm'); const loginForm = await screen.findAllByLabelText(hintText); expect(loginForm).toHaveLength(1); @@ -202,16 +151,17 @@ async function signInAndGetApp(): Promise { await waitForBatchedUpdatesWithAct(); - User.subscribeToUserEvents(); + subscribeToUserEvents(); await waitForBatchedUpdates(); await act(async () => { - // Simulate setting an unread report and personal details + // Simulate setting a report and personal details await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, { reportID: REPORT_ID, reportName: CONST.REPORT.DEFAULT_REPORT_NAME, lastMessageText: 'Test', + lastReadTime: format(new Date(), CONST.DATE.FNS_DB_FORMAT_STRING), participants: { [USER_B_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, [USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, @@ -229,6 +179,7 @@ async function signInAndGetApp(): Promise { reportID: COMMENT_LINKING_REPORT_ID, reportName: CONST.REPORT.DEFAULT_REPORT_NAME, lastMessageText: 'Test', + lastReadTime: format(new Date(), CONST.DATE.FNS_DB_FORMAT_STRING), participants: {[USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, lastActorAccountID: USER_A_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, @@ -253,7 +204,7 @@ async function signInAndGetApp(): Promise { }); // We manually setting the sidebar as loaded since the onLayout event does not fire in tests - AppActions.setSidebarLoaded(); + setSidebarLoaded(); }); await waitForBatchedUpdatesWithAct(); @@ -261,7 +212,7 @@ async function signInAndGetApp(): Promise { describe('Pagination', () => { afterEach(async () => { - await SequentialQueue.waitForIdle(); + await waitForIdle(); await act(async () => { await Onyx.clear(); diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index ce99500a0f56..db582fc6ac10 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -8,22 +8,23 @@ import {AppState, DeviceEventEmitter} from 'react-native'; import type {TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import * as CollectionUtils from '@libs/CollectionUtils'; +import {setSidebarLoaded} from '@libs/actions/App'; +import {addComment, deleteReportComment, markCommentAsUnread, readNewestAction} from '@libs/actions/Report'; +import {subscribeToUserEvents} from '@libs/actions/User'; +import {lastItem} from '@libs/CollectionUtils'; import DateUtils from '@libs/DateUtils'; -import * as Localize from '@libs/Localize'; +import {translateLocal} from '@libs/Localize'; import LocalNotification from '@libs/Notification/LocalNotification'; -import * as NumberUtils from '@libs/NumberUtils'; +import {rand64} from '@libs/NumberUtils'; import {getReportActionText} from '@libs/ReportActionsUtils'; import FontUtils from '@styles/utils/FontUtils'; -import * as AppActions from '@userActions/App'; -import * as Report from '@userActions/Report'; -import * as User from '@userActions/User'; import App from '@src/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; import PusherHelper from '../utils/PusherHelper'; +import {triggerListLayout} from '../utils/ReportTestUtils'; import * as TestHelper from '../utils/TestHelper'; import {navigateToSidebarOption} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -46,7 +47,7 @@ beforeEach(() => { }); function scrollUpToRevealNewMessagesBadge() { - const hintText = Localize.translateLocal('sidebarScreen.listOfChatMessages'); + const hintText = translateLocal('sidebarScreen.listOfChatMessages'); fireEvent.scroll(screen.getByLabelText(hintText), { nativeEvent: { contentOffset: { @@ -67,7 +68,7 @@ function scrollUpToRevealNewMessagesBadge() { } function isNewMessagesBadgeVisible(): boolean { - const hintText = Localize.translateLocal('accessibilityHints.scrollToNewestMessages'); + const hintText = translateLocal('accessibilityHints.scrollToNewestMessages'); const badge = screen.queryByAccessibilityHint(hintText); const badgeProps = badge?.props as {style: ViewStyle}; const transformStyle = badgeProps.style.transform?.[0] as {translateY: number}; @@ -76,7 +77,7 @@ function isNewMessagesBadgeVisible(): boolean { } function navigateToSidebar(): Promise { - const hintText = Localize.translateLocal('accessibilityHints.navigateToChatsList'); + const hintText = translateLocal('accessibilityHints.navigateToChatsList'); const reportHeaderBackButton = screen.queryByAccessibilityHint(hintText); if (reportHeaderBackButton) { fireEvent(reportHeaderBackButton, 'press'); @@ -85,7 +86,7 @@ function navigateToSidebar(): Promise { } function areYouOnChatListScreen(): boolean { - const hintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const hintText = translateLocal('sidebarScreen.listOfChats'); const sidebarLinks = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); return !sidebarLinks?.at(0)?.props?.accessibilityElementsHidden; @@ -110,7 +111,7 @@ function signInAndGetAppWithUnreadChat(): Promise { return waitForBatchedUpdatesWithAct() .then(async () => { await waitForBatchedUpdatesWithAct(); - const hintText = Localize.translateLocal('loginForm.loginForm'); + const hintText = translateLocal('loginForm.loginForm'); const loginForm = screen.queryAllByLabelText(hintText); expect(loginForm).toHaveLength(1); @@ -120,7 +121,7 @@ function signInAndGetAppWithUnreadChat(): Promise { return waitForBatchedUpdatesWithAct(); }) .then(() => { - User.subscribeToUserEvents(); + subscribeToUserEvents(); return waitForBatchedUpdates(); }) .then(async () => { @@ -142,7 +143,7 @@ function signInAndGetAppWithUnreadChat(): Promise { lastActorAccountID: USER_B_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, }); - const createdReportActionID = NumberUtils.rand64().toString(); + const createdReportActionID = rand64().toString(); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { [createdReportActionID]: { actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, @@ -177,7 +178,7 @@ function signInAndGetAppWithUnreadChat(): Promise { }); // We manually setting the sidebar as loaded since the onLayout event does not fire in tests - AppActions.setSidebarLoaded(); + setSidebarLoaded(); return waitForBatchedUpdatesWithAct(); }); } @@ -202,35 +203,36 @@ describe('Unread Indicators', () => { expect((LocalNotification.showCommentNotification as jest.Mock).mock.calls).toHaveLength(0); // Verify the sidebar links are rendered - const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinksHintText = translateLocal('sidebarScreen.listOfChats'); const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); expect(sidebarLinks).toHaveLength(1); // Verify there is only one option in the sidebar - const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRowsHintText = translateLocal('accessibilityHints.navigatesToChat'); const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); expect(optionRows).toHaveLength(1); // And that the text is bold - const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameHintText = translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameText = screen.queryByLabelText(displayNameHintText); expect((displayNameText?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); return navigateToSidebarOption(0); }) .then(async () => { + triggerListLayout(); await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); // That the report actions are visible along with the created action - const welcomeMessageHintText = Localize.translateLocal('accessibilityHints.chatWelcomeMessage'); + const welcomeMessageHintText = translateLocal('accessibilityHints.chatWelcomeMessage'); const createdAction = screen.queryByLabelText(welcomeMessageHintText); expect(createdAction).toBeTruthy(); - const reportCommentsHintText = Localize.translateLocal('accessibilityHints.chatMessage'); + const reportCommentsHintText = translateLocal('accessibilityHints.chatMessage'); const reportComments = screen.queryAllByLabelText(reportCommentsHintText); expect(reportComments).toHaveLength(9); // Since the last read timestamp is the timestamp of action 3 we should have an unread indicator above the next "unread" action which will // have actionID of 4 - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); const reportActionID = unreadIndicator.at(0)?.props?.['data-action-id'] as string; @@ -246,7 +248,7 @@ describe('Unread Indicators', () => { .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); // Verify the unread indicator is present - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); }) @@ -269,7 +271,7 @@ describe('Unread Indicators', () => { }) .then(() => { // Verify the unread indicator is not present - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(0); // Tap on the chat again @@ -277,7 +279,7 @@ describe('Unread Indicators', () => { }) .then(() => { // Verify the unread indicator is not present - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(0); expect(areYouOnChatListScreen()).toBe(false); @@ -290,8 +292,8 @@ describe('Unread Indicators', () => { const NEW_REPORT_ID = '2'; const NEW_REPORT_CREATED_DATE = subSeconds(new Date(), 5); const NEW_REPORT_FIST_MESSAGE_CREATED_DATE = addSeconds(NEW_REPORT_CREATED_DATE, 1); - const createdReportActionID = NumberUtils.rand64(); - const commentReportActionID = NumberUtils.rand64(); + const createdReportActionID = rand64(); + const commentReportActionID = rand64(); PusherHelper.emitOnyxUpdate([ { onyxMethod: Onyx.METHOD.MERGE, @@ -347,11 +349,11 @@ describe('Unread Indicators', () => { }) .then(() => { // // Verify the new report option appears in the LHN - const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRowsHintText = translateLocal('accessibilityHints.navigatesToChat'); const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); expect(optionRows).toHaveLength(2); // Verify the text for both chats are bold indicating that nothing has not yet been read - const displayNameHintTexts = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameHintTexts = translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameTexts = screen.queryAllByLabelText(displayNameHintTexts); expect(displayNameTexts).toHaveLength(2); const firstReportOption = displayNameTexts.at(0); @@ -369,7 +371,7 @@ describe('Unread Indicators', () => { .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread - const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); expect(displayNameTexts).toHaveLength(2); expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.normal); @@ -385,12 +387,12 @@ describe('Unread Indicators', () => { .then(() => { // It's difficult to trigger marking a report comment as unread since we would have to mock the long press event and then // another press on the context menu item so we will do it via the action directly and then test if the UI has updated properly - Report.markCommentAsUnread(REPORT_ID, reportAction3CreatedDate); + markCommentAsUnread(REPORT_ID, reportAction3CreatedDate); return waitForBatchedUpdates(); }) .then(() => { // Verify the indicator appears above the last action - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); const reportActionID = unreadIndicator.at(0)?.props?.['data-action-id'] as string; @@ -403,7 +405,7 @@ describe('Unread Indicators', () => { .then(navigateToSidebar) .then(() => { // Verify the report is marked as unread in the sidebar - const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameTexts = screen.queryAllByLabelText(hintText); expect(displayNameTexts).toHaveLength(1); expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); @@ -415,7 +417,7 @@ describe('Unread Indicators', () => { .then(() => navigateToSidebar()) .then(() => { // Verify the report is now marked as read - const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameTexts = screen.queryAllByLabelText(hintText); expect(displayNameTexts).toHaveLength(1); expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(undefined); @@ -425,7 +427,7 @@ describe('Unread Indicators', () => { return navigateToSidebarOption(0); }) .then(() => { - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(0); @@ -445,16 +447,16 @@ describe('Unread Indicators', () => { }) .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); // Leave a comment as the current user and verify the indicator is removed - Report.addComment(REPORT_ID, 'Current User Comment 1'); + addComment(REPORT_ID, 'Current User Comment 1'); return waitForBatchedUpdates(); }) .then(() => { - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); })); @@ -469,7 +471,7 @@ describe('Unread Indicators', () => { return navigateToSidebarOption(0); }) .then(() => { - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); @@ -478,16 +480,16 @@ describe('Unread Indicators', () => { }) .then(() => navigateToSidebarOption(0)) .then(() => { - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(0); // Mark a previous comment as unread and verify the unread action indicator returns - Report.markCommentAsUnread(REPORT_ID, reportAction9CreatedDate); + markCommentAsUnread(REPORT_ID, reportAction9CreatedDate); return waitForBatchedUpdates(); }) .then(() => { - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); let unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); @@ -511,14 +513,15 @@ describe('Unread Indicators', () => { signInAndGetAppWithUnreadChat() // Navigate to the chat and simulate leaving a comment from the current user .then(() => navigateToSidebarOption(0)) + .then(() => triggerListLayout()) .then(() => { // Leave a comment as the current user - Report.addComment(REPORT_ID, 'Current User Comment 1'); + addComment(REPORT_ID, 'Current User Comment 1'); return waitForBatchedUpdates(); }) .then(() => { // Simulate the response from the server so that the comment can be deleted in this test - lastReportAction = reportActions ? CollectionUtils.lastItem(reportActions) : undefined; + lastReportAction = reportActions ? lastItem(reportActions) : undefined; Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, { lastMessageText: getReportActionText(lastReportAction), lastActorAccountID: lastReportAction?.actorAccountID, @@ -528,7 +531,7 @@ describe('Unread Indicators', () => { }) .then(() => { // Verify the chat preview text matches the last comment from the current user - const hintText = Localize.translateLocal('accessibilityHints.lastChatMessagePreview'); + const hintText = translateLocal('accessibilityHints.lastChatMessagePreview'); const alternateText = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); expect(alternateText).toHaveLength(1); @@ -536,12 +539,12 @@ describe('Unread Indicators', () => { expect(screen.getAllByText('Current User Comment 1').at(0)).toBeOnTheScreen(); if (lastReportAction) { - Report.deleteReportComment(REPORT_ID, lastReportAction); + deleteReportComment(REPORT_ID, lastReportAction); } return waitForBatchedUpdates(); }) .then(() => { - const hintText = Localize.translateLocal('accessibilityHints.lastChatMessagePreview'); + const hintText = translateLocal('accessibilityHints.lastChatMessagePreview'); const alternateText = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); expect(alternateText).toHaveLength(1); expect(screen.getAllByText('Comment 9').at(0)).toBeOnTheScreen(); @@ -557,30 +560,31 @@ describe('Unread Indicators', () => { }); await signInAndGetAppWithUnreadChat(); await navigateToSidebarOption(0); + triggerListLayout(); - Report.addComment(REPORT_ID, 'Comment 1'); + addComment(REPORT_ID, 'Comment 1'); await waitForBatchedUpdates(); - const firstNewReportAction = reportActions ? CollectionUtils.lastItem(reportActions) : undefined; + const firstNewReportAction = reportActions ? lastItem(reportActions) : undefined; if (firstNewReportAction) { - Report.markCommentAsUnread(REPORT_ID, firstNewReportAction?.created); + markCommentAsUnread(REPORT_ID, firstNewReportAction?.created); await waitForBatchedUpdates(); - Report.addComment(REPORT_ID, 'Comment 2'); + addComment(REPORT_ID, 'Comment 2'); await waitForBatchedUpdates(); - Report.deleteReportComment(REPORT_ID, firstNewReportAction); + deleteReportComment(REPORT_ID, firstNewReportAction); await waitForBatchedUpdates(); } - const secondNewReportAction = reportActions ? CollectionUtils.lastItem(reportActions) : undefined; + const secondNewReportAction = reportActions ? lastItem(reportActions) : undefined; - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(1); const reportActionID = unreadIndicator.at(0)?.props?.['data-action-id'] as string; @@ -593,7 +597,7 @@ describe('Unread Indicators', () => { // Given a read report await signInAndGetAppWithUnreadChat(); - Report.readNewestAction(REPORT_ID, true); + readNewestAction(REPORT_ID, true); await waitForBatchedUpdates(); @@ -605,7 +609,7 @@ describe('Unread Indicators', () => { }); // Then the new line indicator shouldn't be displayed - const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); expect(unreadIndicator).toHaveLength(0); }); diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts index 167e04d85015..bf763636a9fb 100644 --- a/tests/utils/ReportTestUtils.ts +++ b/tests/utils/ReportTestUtils.ts @@ -1,6 +1,11 @@ +import * as NativeNavigation from '@react-navigation/native'; +import {act, fireEvent, screen, within} from '@testing-library/react-native'; +import {translateLocal} from '@libs/Localize'; import type {ReportAction, ReportActions} from '@src/types/onyx'; import type ReportActionName from '@src/types/onyx/ReportActionName'; +import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; import createRandomReportAction from './collections/reportActions'; +import waitForBatchedUpdatesWithAct from './waitForBatchedUpdatesWithAct'; const actionNames: ReportActionName[] = ['ADDCOMMENT', 'IOU', 'REPORTPREVIEW', 'CLOSED']; @@ -69,4 +74,67 @@ const getMockedReportActionsMap = (length = 100): ReportActions => { return Object.assign({}, ...mockReports) as ReportActions; }; -export {getFakeReportAction, getMockedSortedReportActions, getMockedReportActionsMap}; +const REPORT_ID = '1'; +const LIST_SIZE = { + width: 300, + height: 400, +}; +const LIST_CONTENT_SIZE = { + width: 300, + height: 600, +}; + +function getReportScreen(reportID = REPORT_ID) { + return screen.getByTestId(`report-screen-${reportID}`); +} + +function scrollToOffset(offset: number) { + const hintText = translateLocal('sidebarScreen.listOfChatMessages'); + fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { + nativeEvent: { + contentOffset: { + y: offset, + }, + contentSize: LIST_CONTENT_SIZE, + layoutMeasurement: LIST_SIZE, + }, + }); +} + +function triggerListLayout(reportID?: string) { + const report = getReportScreen(reportID); + fireEvent(within(report).getByTestId('report-actions-view-wrapper'), 'onLayout', { + nativeEvent: { + layout: { + x: 0, + y: 0, + ...LIST_SIZE, + }, + }, + }); + + fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); +} + +async function navigateToSidebarOption(reportID: string): Promise { + const optionRow = screen.getByTestId(reportID); + fireEvent(optionRow, 'press'); + await act(() => { + (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); + }); + // ReportScreen relies on the onLayout event to receive updates from onyx. + triggerListLayout(reportID); + await waitForBatchedUpdatesWithAct(); +} + +export { + getFakeReportAction, + getMockedSortedReportActions, + getMockedReportActionsMap, + REPORT_ID, + LIST_CONTENT_SIZE, + getReportScreen, + scrollToOffset, + triggerListLayout, + navigateToSidebarOption, +}; From d3458ed29d88961d311907ea2c15845907bf81d7 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 25 Feb 2025 20:35:52 -0500 Subject: [PATCH 002/216] Remove not needed code --- src/pages/home/report/ReportActionsList.tsx | 27 +++------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 0979f04e3498..2916105c774f 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -275,7 +275,7 @@ function ReportActionsList({ /** * The reportActionID the unread marker should display above */ - const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useMemo(() => { + const unreadMarkerReportActionID = useMemo(() => { const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { const nextMessage = sortedVisibleReportActions.at(index + 1); const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime); @@ -332,11 +332,11 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler if (reportAction && shouldDisplayNewMarker(reportAction, index)) { - return {unreadMarkerReportActionID: reportAction.reportActionID, unreadMarkerReportActionIndex: index}; + return reportAction.reportActionID; } } - return {unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}; + return null; }, [accountID, earliestReceivedOfflineMessageIndex, prevSortedVisibleReportActionsObjects, sortedVisibleReportActions, unreadMarkerTime]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; @@ -639,27 +639,6 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isFocused, isVisible]); - // Handles scrolling to the unread marker. - // If we have an unread marker initially we do not need to scroll to it as this - // will be handled by the list `initialScrollKey`. - const didScrollToUnreadMarker = useRef(unreadMarkerReportActionIndex >= 0); - useEffect(() => { - if (unreadMarkerReportActionIndex === -1 || didScrollToUnreadMarker.current) { - return; - } - didScrollToUnreadMarker.current = true; - InteractionManager.runAfterInteractions(() => { - reportScrollManager.ref?.current?.scrollToIndex({ - index: unreadMarkerReportActionIndex, - animated: true, - // This scrolls the unread action at the top of the screen. - viewPosition: 1, - // This makes sure that the unread indicator doesn't get cut off. - viewOffset: -64, - }); - }); - }, [reportScrollManager, unreadMarkerReportActionIndex]); - const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => ( Date: Sat, 15 Mar 2025 17:06:15 -0400 Subject: [PATCH 003/216] Fix loading at unread when no cache --- src/pages/home/ReportScreen.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index f29a4d85fc56..15821b739052 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -63,6 +63,7 @@ import { isPolicyExpenseChat, isTaskReport, isTrackExpenseReport, + isUnread, isValidReportIDFromPath, } from '@libs/ReportUtils'; import {isNumeric} from '@libs/ValidationUtils'; @@ -674,6 +675,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const onComposerFocus = useCallback(() => setIsComposerFocus(true), []); const onComposerBlur = useCallback(() => setIsComposerFocus(false), []); + // When opening an unread report, it is very likely that the message we will open to is not the latest, which is the + // only one we will have in cache. + const isLoadingUnreadReportNoCache = isUnread(report) && reportMetadata.isLoadingInitialReportActions && reportActions.length <= 1; + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. // We aim to display a loader first, then fetch relevant reportActions, and finally show them. @@ -727,7 +732,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - {!!report && !isLoadingApp ? ( + {!!report && !isLoadingApp && !isLoadingUnreadReportNoCache ? ( Date: Tue, 18 Mar 2025 17:20:48 +0100 Subject: [PATCH 004/216] fix: `ReportUtils.isUnread` signature changed --- src/pages/home/ReportScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 15821b739052..582fcccd4993 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -288,6 +288,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const isLinkedMessagePageReady = isLinkedMessageAvailable && (reportActions.length - indexOfLinkedMessage >= CONST.REPORT.MIN_INITIAL_REPORT_ACTION_COUNT || doesCreatedActionExists()); const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], isOffline); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [transactionThreadReportActions = {}] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`); const combinedReportActions = getCombinedReportActions(reportActions, transactionThreadReportID ?? null, Object.values(transactionThreadReportActions)); const lastReportAction = [...combinedReportActions, parentReportAction].find((action) => canEditReportAction(action) && !isMoneyRequestAction(action)); @@ -677,7 +678,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When opening an unread report, it is very likely that the message we will open to is not the latest, which is the // only one we will have in cache. - const isLoadingUnreadReportNoCache = isUnread(report) && reportMetadata.isLoadingInitialReportActions && reportActions.length <= 1; + const isLoadingUnreadReportNoCache = isUnread(report, transactionThreadReport) && reportMetadata.isLoadingInitialReportActions && reportActions.length <= 1; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. From 21340a5377db4f0398cdc006c3d5007cc8ec021c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 25 Mar 2025 18:05:17 +0100 Subject: [PATCH 005/216] fix: ts error --- src/pages/home/report/ReportActionsView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index f5b6f04b5a2d..ea31fb48b2d9 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -217,7 +217,6 @@ function ReportActionsView({ const {loadOlderChats, loadNewerChats} = useLoadReportActions({ reportID, - reportActionID, reportActions, allReportActionIDs, transactionThreadReport, From aa6d447bccc188fea80ea72938e82ddb6ac7d31b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Mar 2025 15:25:36 +0100 Subject: [PATCH 006/216] fix: just check whether report is initially loading rather than whether it has cache --- src/pages/home/ReportScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4fb299f71adf..01f3c8e702ee 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -680,7 +680,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When opening an unread report, it is very likely that the message we will open to is not the latest, which is the // only one we will have in cache. - const isLoadingUnreadReportNoCache = isUnread(report, transactionThreadReport) && reportMetadata.isLoadingInitialReportActions && reportActions.length <= 1; + const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && reportMetadata.isLoadingInitialReportActions; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. @@ -735,7 +735,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - {!!report && !isLoadingApp && !isLoadingUnreadReportNoCache ? ( + {!!report && !isLoadingApp && !isInitiallyLoadingReport ? ( Date: Wed, 16 Apr 2025 16:00:31 +0200 Subject: [PATCH 007/216] fix: add `canBeMissing` options to `useOnyx` --- src/pages/home/ReportScreen.tsx | 33 +++++++++++---------- src/pages/home/report/ReportActionsList.tsx | 4 +-- src/pages/home/report/ReportActionsView.tsx | 5 ++-- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 92419e45316d..7601d6226a1e 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -146,17 +146,18 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const {activeWorkspaceID} = useActiveWorkspace(); const currentReportIDValue = useCurrentReportID(); - const [modal] = useOnyx(ONYXKEYS.MODAL); - const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`, {initialValue: false}); - const [accountManagerReportID] = useOnyx(ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID); - const [accountManagerReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(accountManagerReportID)}`); - const [userLeavingStatus] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportIDFromRoute}`, {initialValue: false}); - const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {allowStaleData: true}); - const [reportNameValuePairsOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportIDFromRoute}`, {allowStaleData: true}); - const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}}); + const [modal] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: true}); + const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`, {initialValue: false, canBeMissing: true}); + const [accountManagerReportID] = useOnyx(ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, {canBeMissing: true}); + const [accountManagerReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(accountManagerReportID)}`, {canBeMissing: true}); + const [userLeavingStatus] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportIDFromRoute}`, {initialValue: false, canBeMissing: false}); + const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {allowStaleData: true, canBeMissing: true}); + const [reportNameValuePairsOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportIDFromRoute}`, {allowStaleData: true, canBeMissing: true}); + const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`, {canBeMissing: true}); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}, canBeMissing: true}); const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportOnyx?.parentReportID)}`, { canEvict: false, + canBeMissing: true, selector: (parentReportActions) => getParentReportAction(parentReportActions, reportOnyx?.parentReportActionID), }); const deletedParentAction = isDeletedParentAction(parentReportAction); @@ -187,7 +188,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { navigation.setParams({reportID: lastAccessedReportID}); }, [activeWorkspaceID, canUseDefaultRooms, navigation, route]); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); const chatWithAccountManagerText = useMemo(() => { if (accountManagerReportID) { const participants = getParticipantsAccountIDsForDisplay(accountManagerReport, false, true); @@ -262,9 +263,9 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const lastReportIDFromRoute = usePrevious(reportIDFromRoute); const [isLinkingToMessage, setIsLinkingToMessage] = useState(!!reportActionIDFromRoute); - const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.accountID}); - const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.email}); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.accountID, canBeMissing: true}); + const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.email, canBeMissing: true}); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const {reportActions, linkedAction, sortedAllReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionIDFromRoute); const [isBannerVisible, setIsBannerVisible] = useState(true); @@ -293,8 +294,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const isLinkedMessagePageReady = isLinkedMessageAvailable && (reportActions.length - indexOfLinkedMessage >= CONST.REPORT.MIN_INITIAL_REPORT_ACTION_COUNT || doesCreatedActionExists()); const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], isOffline); - const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const [transactionThreadReportActions = {}] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); + const [transactionThreadReportActions = {}] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, {canBeMissing: true}); const combinedReportActions = getCombinedReportActions(reportActions, transactionThreadReportID ?? null, Object.values(transactionThreadReportActions)); const lastReportAction = [...combinedReportActions, parentReportAction].find((action) => canEditReportAction(action) && !isMoneyRequestAction(action)); const isSingleTransactionView = isMoneyRequest(report) || isTrackExpenseReport(report); @@ -388,7 +389,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { () => !!linkedAction && isWhisperAction(linkedAction) && !(linkedAction?.whisperedToAccountIDs ?? []).includes(currentUserAccountID), [currentUserAccountID, linkedAction], ); - const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL); + const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, {canBeMissing: true}); useEffect(() => { if (!isFocused || !deleteTransactionNavigateBackUrl) { diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 8c1dee8cafad..aeeadf5b9cc1 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -167,8 +167,8 @@ function ReportActionsList({ const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); - const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {canBeMissing: true}); + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID, canBeMissing: true}); const participantsContext = useContext(PersonalDetailsContext); const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 8c2f80fe4b66..9611ef221a69 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -78,10 +78,11 @@ function ReportActionsView({ const reactionListRef = useContext(ReactionListContext); const route = useRoute>(); const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`, { + canBeMissing: true, selector: (reportActions: OnyxEntry) => getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction(report), true), }); - const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`, {canBeMissing: true}); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const prevTransactionThreadReport = usePrevious(transactionThreadReport); const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); From 2f024209e4c54dcaa7f46f206aac864aea7ab9c9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 16 Apr 2025 16:03:42 +0200 Subject: [PATCH 008/216] fix: default string id lint errors --- src/pages/home/report/ReportActionsView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 9611ef221a69..3dc76273cc2b 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -77,11 +77,11 @@ function ReportActionsView({ useCopySelectionHelper(); const reactionListRef = useContext(ReactionListContext); const route = useRoute>(); - const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`, { + const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, { canBeMissing: true, selector: (reportActions: OnyxEntry) => getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction(report), true), }); - const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`, {canBeMissing: true}); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const prevTransactionThreadReport = usePrevious(transactionThreadReport); const reportActionID = route?.params?.reportActionID; From 30eada3abc48004c26d5dd126a8686544258fdd6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 16 Apr 2025 16:13:55 +0200 Subject: [PATCH 009/216] fix: make canBeMissing false --- src/pages/home/ReportScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 7601d6226a1e..268b1749cac5 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -150,7 +150,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`, {initialValue: false, canBeMissing: true}); const [accountManagerReportID] = useOnyx(ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, {canBeMissing: true}); const [accountManagerReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(accountManagerReportID)}`, {canBeMissing: true}); - const [userLeavingStatus] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportIDFromRoute}`, {initialValue: false, canBeMissing: false}); + const [userLeavingStatus] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportIDFromRoute}`, {initialValue: false, canBeMissing: true}); const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {allowStaleData: true, canBeMissing: true}); const [reportNameValuePairsOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportIDFromRoute}`, {allowStaleData: true, canBeMissing: true}); const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`, {canBeMissing: true}); From b9b1cbe9bf2d006bd4ac1e88c67b3e18ed9ca783 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 16 Apr 2025 22:38:26 +0200 Subject: [PATCH 010/216] fix: add condition for when user is offline --- src/pages/home/ReportScreen.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 268b1749cac5..583130b3417e 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -715,7 +715,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When opening an unread report, it is very likely that the message we will open to is not the latest, which is the // only one we will have in cache. - const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && reportMetadata.isLoadingInitialReportActions; + const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && reportActions.length <= 1; + const isInitiallyLoadingReportWhileOffline = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && isOffline; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. @@ -770,7 +771,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - {!report || isInitiallyLoadingReport ? ( + {!report || isInitiallyLoadingReport || isInitiallyLoadingReportWhileOffline ? ( ) : ( Date: Sun, 20 Apr 2025 12:14:07 +0200 Subject: [PATCH 011/216] fix: scroll to end if first message is unread --- .../BaseInvertedFlatList/index.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index b9b477e2ad41..f15c6e2ce180 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -49,6 +49,26 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE))); }, [currentDataIndex, data, isInitialData]); + const listRef = useRef(null); + + // If the unread message is within the first pagination items, we need to manually scroll to the top, + // because otherwise the content would shift up by new messages loading and filling up the page. + const [shouldInitiallyScrollToFirstMessage, setShouldInitiallyScrollToFirstMessage] = useState( + () => data.length >= CONST.PAGINATION_SIZE && currentDataIndex >= Math.max(0, data.length - CONST.PAGINATION_SIZE), + ); + useEffect(() => { + // Scroll to the end once the first page of items or the whole list is loaded. + if (!shouldInitiallyScrollToFirstMessage || (displayedData.length !== data.length && displayedData.length < CONST.PAGINATION_SIZE)) { + return; + } + + requestAnimationFrame(() => { + listRef.current?.scrollToEnd(); + }); + + setShouldInitiallyScrollToFirstMessage(false); + }, [data.length, displayedData.length, shouldInitiallyScrollToFirstMessage]); + const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); const dataIndexDifference = data.length - displayedData.length; @@ -98,7 +118,6 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa return config; }, [shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - const listRef = useRef(null); useImperativeHandle(ref, () => { // If we're trying to scroll at the start of the list we need to make sure to // render all items. From 1c75d7a39bf11cbb8174c67d4631e204d5ecf1c8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 22 Apr 2025 18:17:40 +0200 Subject: [PATCH 012/216] fix: only scroll to top once --- .../BaseInvertedFlatList/index.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index f15c6e2ce180..1de1ef2e66e2 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -53,11 +53,21 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa // If the unread message is within the first pagination items, we need to manually scroll to the top, // because otherwise the content would shift up by new messages loading and filling up the page. - const [shouldInitiallyScrollToFirstMessage, setShouldInitiallyScrollToFirstMessage] = useState( + const isUnreadMessageOnFirstPage = useCallback( () => data.length >= CONST.PAGINATION_SIZE && currentDataIndex >= Math.max(0, data.length - CONST.PAGINATION_SIZE), + [data.length, currentDataIndex], ); + + const [shouldInitiallyScrollToFirstMessage, setShouldInitiallyScrollToFirstMessage] = useState(isUnreadMessageOnFirstPage); + useEffect(() => { + if (shouldInitiallyScrollToFirstMessage !== undefined || !isUnreadMessageOnFirstPage) { + return; + } + setShouldInitiallyScrollToFirstMessage(true); + }, [currentDataIndex, isUnreadMessageOnFirstPage, shouldInitiallyScrollToFirstMessage]); + useEffect(() => { - // Scroll to the end once the first page of items or the whole list is loaded. + // Scroll to the end once the first page of items or the whole list is loaded, if there not that many items. if (!shouldInitiallyScrollToFirstMessage || (displayedData.length !== data.length && displayedData.length < CONST.PAGINATION_SIZE)) { return; } From 6a2419ea09a6908939e118405a903cbc2f3c6a89 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 22 Apr 2025 18:35:32 +0200 Subject: [PATCH 013/216] fix: improve scrolling code --- .../BaseInvertedFlatList/index.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 1de1ef2e66e2..0a0666ee6405 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -6,6 +6,10 @@ import usePrevious from '@hooks/usePrevious'; import CONST from '@src/CONST'; import RenderTaskQueue from './RenderTaskQueue'; +// For new reports, we only want to scroll to the top if the unread message is within the first 2 items. +// The first action within a report is usually a "CREATED" action, while the second item is the first message/action. +const FIRST_MESSAGE_SCROLL_THRESHOLD = 2; + // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { if (item != null) { @@ -53,18 +57,18 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa // If the unread message is within the first pagination items, we need to manually scroll to the top, // because otherwise the content would shift up by new messages loading and filling up the page. - const isUnreadMessageOnFirstPage = useCallback( - () => data.length >= CONST.PAGINATION_SIZE && currentDataIndex >= Math.max(0, data.length - CONST.PAGINATION_SIZE), + const isFirstMessageUnread = useCallback( + () => data.length >= FIRST_MESSAGE_SCROLL_THRESHOLD && currentDataIndex >= Math.max(0, data.length - FIRST_MESSAGE_SCROLL_THRESHOLD), [data.length, currentDataIndex], ); - const [shouldInitiallyScrollToFirstMessage, setShouldInitiallyScrollToFirstMessage] = useState(isUnreadMessageOnFirstPage); + const [shouldInitiallyScrollToFirstMessage, setShouldInitiallyScrollToFirstMessage] = useState(isFirstMessageUnread); useEffect(() => { - if (shouldInitiallyScrollToFirstMessage !== undefined || !isUnreadMessageOnFirstPage) { + if (shouldInitiallyScrollToFirstMessage !== undefined || !isFirstMessageUnread) { return; } setShouldInitiallyScrollToFirstMessage(true); - }, [currentDataIndex, isUnreadMessageOnFirstPage, shouldInitiallyScrollToFirstMessage]); + }, [currentDataIndex, isFirstMessageUnread, shouldInitiallyScrollToFirstMessage]); useEffect(() => { // Scroll to the end once the first page of items or the whole list is loaded, if there not that many items. From 7dd1ba92832c792350a51fc59ea37df8002ad2ff Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Apr 2025 16:49:53 +0200 Subject: [PATCH 014/216] fix: typecheck --- src/pages/home/ReportScreen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 9639efdb0deb..81c2ea3c1247 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -158,7 +158,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}, canBeMissing: false}); const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportOnyx?.parentReportID)}`, { canEvict: false, - canBeMissing: true, selector: (parentReportActions) => getParentReportAction(parentReportActions, reportOnyx?.parentReportActionID), canBeMissing: true, }); From ab7fefac6c3a5d10e9e583fe8099c0d33f965b01 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 24 Apr 2025 17:17:46 +0200 Subject: [PATCH 015/216] fix: update unread indicator test --- tests/ui/UnreadIndicatorsTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 4ec44ad4a5d8..346bae613c1a 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -230,7 +230,7 @@ describe('Unread Indicators', () => { expect(createdAction).toBeTruthy(); const reportCommentsHintText = translateLocal('accessibilityHints.chatMessage'); const reportComments = screen.queryAllByLabelText(reportCommentsHintText); - expect(reportComments).toHaveLength(9); + expect(reportComments).toHaveLength(4); // Since the last read timestamp is the timestamp of action 3 we should have an unread indicator above the next "unread" action which will // have actionID of 4 const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); From 73174dc8f2324cae413929c834e55067fab9e12a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Apr 2025 16:44:24 +0200 Subject: [PATCH 016/216] fix: don't immediately mark last message as read --- .../BaseInvertedFlatList/index.tsx | 4 +- src/pages/home/report/ReportActionsList.tsx | 47 +++++++++++++------ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 0a0666ee6405..9ed8c9f2f8bb 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -28,12 +28,13 @@ type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' data: T[]; renderItem: ListRenderItem; initialScrollKey?: string | null; + onInitiallyLoaded?: () => void; }; const AUTOSCROLL_TO_TOP_THRESHOLD = 250; function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, ...rest} = props; + const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, onInitiallyLoaded, ...rest} = props; // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more // previous items, until everything is rendered. We also progressively render new data that is added at the start of the @@ -82,6 +83,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa setShouldInitiallyScrollToFirstMessage(false); }, [data.length, displayedData.length, shouldInitiallyScrollToFirstMessage]); + onInitiallyLoaded?.(); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 5c51850bcf50..310eb405ea42 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -344,27 +344,33 @@ function ReportActionsList({ prevReportID = report.reportID; }, [report.reportID]); + const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); + const [isScrolledToStart, setIsScrolledToStart] = useState(false); useEffect(() => { if (report.reportID !== prevReportID) { return; } - if (isUnread(report, transactionThreadReport)) { - // On desktop, when the notification center is displayed, isVisible will return false. - // 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) { - readNewestAction(report.reportID); - if (isFromNotification) { - Navigation.setParams({referrer: undefined}); - } - } else { - readActionSkipped.current = true; + if (!isUnread(report, transactionThreadReport) || !isListInitiallyLoaded) { + return; + } + + // On desktop, when the notification center is displayed, isVisible will return false. + // 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) && isScrolledToStart) { + readNewestAction(report.reportID); + + if (isFromNotification) { + Navigation.setParams({referrer: undefined}); } + } else { + readActionSkipped.current = true; } // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible]); + }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, isScrolledToStart, isListInitiallyLoaded]); useEffect(() => { if (linkedReportActionID || unreadMarkerReportActionID) { @@ -482,6 +488,16 @@ function ReportActionsList({ const trackVerticalScrolling = (event: NativeSyntheticEvent) => { scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; + + // Once we hit the start of the list, we want to trigger the read last message logic, if the message is unread + if (scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD) { + if (!isScrolledToStart) { + setIsScrolledToStart(true); + } + } else if (isScrolledToStart) { + setIsScrolledToStart(false); + } + handleUnreadFloatingButton(); onScroll?.(event); }; @@ -673,7 +689,7 @@ function ReportActionsList({ return ; }, [shouldShowSkeleton]); - const onStartReached = useCallback(() => { + const handleStartReached = useCallback(() => { if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); return; @@ -715,11 +731,12 @@ function ReportActionsList({ initialNumToRender={initialNumToRender} onEndReached={onEndReached} onEndReachedThreshold={0.75} - onStartReached={onStartReached} + onStartReached={handleStartReached} onStartReachedThreshold={0.75} ListHeaderComponent={listHeaderComponent} ListFooterComponent={listFooterComponent} keyboardShouldPersistTaps="handled" + onInitiallyLoaded={() => setIsListInitiallyLoaded(true)} onLayout={onLayoutInner} onScroll={trackVerticalScrolling} onScrollToIndexFailed={onScrollToIndexFailed} From d424e78037de802b872fdd1fbdaab7953b4d0182 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Apr 2025 16:44:35 +0200 Subject: [PATCH 017/216] fix: improve scrolling logic --- src/CONST.ts | 1 + .../BaseInvertedFlatList/RenderTaskQueue.tsx | 1 + .../BaseInvertedFlatList/index.tsx | 70 +++++++++++-------- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index f83f16644b09..8389ce713553 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5638,6 +5638,7 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', PAGINATION_SIZE: 15, + MIN_ITEMS_TO_RENDER: 30, /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx index 0cc086c9cdf3..84b5f8dfcaa7 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx @@ -49,3 +49,4 @@ class RenderTaskQueue { } export default RenderTaskQueue; +export type {RenderInfo}; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 9ed8c9f2f8bb..0fc0133d0aef 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -4,12 +4,9 @@ import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFl import FlatList from '@components/FlatList'; import usePrevious from '@hooks/usePrevious'; import CONST from '@src/CONST'; +import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; -// For new reports, we only want to scroll to the top if the unread message is within the first 2 items. -// The first action within a report is usually a "CREATED" action, while the second item is the first message/action. -const FIRST_MESSAGE_SCROLL_THRESHOLD = 2; - // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { if (item != null) { @@ -47,43 +44,46 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa }); const [isInitialData, setIsInitialData] = useState(true); const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); + + const isMessageWithinMinItemsToRender = currentDataIndex > Math.max(0, data.length - CONST.MIN_ITEMS_TO_RENDER); const displayedData = useMemo(() => { if (currentDataIndex <= 0) { return data; } + + if (isMessageWithinMinItemsToRender) { + return data.slice(-CONST.MIN_ITEMS_TO_RENDER); + } + return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE))); - }, [currentDataIndex, data, isInitialData]); + }, [currentDataIndex, data, isInitialData, isMessageWithinMinItemsToRender]); const listRef = useRef(null); - // If the unread message is within the first pagination items, we need to manually scroll to the top, - // because otherwise the content would shift up by new messages loading and filling up the page. - const isFirstMessageUnread = useCallback( - () => data.length >= FIRST_MESSAGE_SCROLL_THRESHOLD && currentDataIndex >= Math.max(0, data.length - FIRST_MESSAGE_SCROLL_THRESHOLD), - [data.length, currentDataIndex], - ); + // Whether we should or should not scroll to the top and whether we have already scrolled to the top. + const [initialScrollState, setInitialScrollState] = useState<'shouldScroll' | 'shouldNotScroll' | 'done'>(isMessageWithinMinItemsToRender ? 'shouldScroll' : 'shouldNotScroll'); - const [shouldInitiallyScrollToFirstMessage, setShouldInitiallyScrollToFirstMessage] = useState(isFirstMessageUnread); + // If the first message becomes unread later and we haven't scrolled to the top yet, we want to scroll to the top. useEffect(() => { - if (shouldInitiallyScrollToFirstMessage !== undefined || !isFirstMessageUnread) { + if (initialScrollState !== 'shouldNotScroll' || !isMessageWithinMinItemsToRender) { return; } - setShouldInitiallyScrollToFirstMessage(true); - }, [currentDataIndex, isFirstMessageUnread, shouldInitiallyScrollToFirstMessage]); + setInitialScrollState('shouldScroll'); + }, [currentDataIndex, initialScrollState, isMessageWithinMinItemsToRender]); useEffect(() => { - // Scroll to the end once the first page of items or the whole list is loaded, if there not that many items. - if (!shouldInitiallyScrollToFirstMessage || (displayedData.length !== data.length && displayedData.length < CONST.PAGINATION_SIZE)) { + // Scroll to the end once the initial data is loaded. + if (isInitialData || initialScrollState === 'done') { return; } - requestAnimationFrame(() => { - listRef.current?.scrollToEnd(); - }); + if (initialScrollState === 'shouldScroll') { + listRef.current?.scrollToEnd({animated: false}); + } - setShouldInitiallyScrollToFirstMessage(false); - }, [data.length, displayedData.length, shouldInitiallyScrollToFirstMessage]); onInitiallyLoaded?.(); + setInitialScrollState('done'); + }, [data.length, displayedData.length, initialScrollState, isInitialData, onInitiallyLoaded]); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); @@ -97,17 +97,25 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa }; }, [renderQueue]); - renderQueue.setHandler((info) => { - if (!isLoadingData) { - onStartReached?.(info); - } - setIsInitialData(false); - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); - }); + const handleRender = useCallback( + (info: RenderInfo) => { + if (!isLoadingData) { + onStartReached?.(info); + } + + if (isInitialData) { + console.log('set is initial data to false'); + setIsInitialData(false); + } + const firstDisplayedItem = displayedData.at(0); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); + }, + [isLoadingData, isInitialData, displayedData, keyExtractor, currentDataIndex, onStartReached], + ); + renderQueue.setHandler(handleRender); const handleStartReached = useCallback( - (info: {distanceFromStart: number}) => { + (info: RenderInfo) => { renderQueue.add(info); }, [renderQueue], From eac544d4619b134ec224fbb10b3352a0d44bebcb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Apr 2025 23:04:30 +0200 Subject: [PATCH 018/216] fix: scrolling logic --- src/CONST.ts | 1 - .../BaseInvertedFlatList/RenderTaskQueue.tsx | 20 ++- .../BaseInvertedFlatList/index.tsx | 115 +++++++++--------- src/pages/home/report/ReportActionsList.tsx | 8 +- 4 files changed, 81 insertions(+), 63 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 8389ce713553..f83f16644b09 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5638,7 +5638,6 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', PAGINATION_SIZE: 15, - MIN_ITEMS_TO_RENDER: 30, /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx index 84b5f8dfcaa7..c1b3b594c43d 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx @@ -9,23 +9,34 @@ class RenderTaskQueue { private isRendering = false; - private handler: (info: RenderInfo) => void = () => {}; + private handler: ((info: RenderInfo) => void) | undefined = undefined; + + private onEndReached: (() => void) | undefined = undefined; private timeout: NodeJS.Timeout | null = null; - add(info: RenderInfo) { + add(info: RenderInfo, startRendering = true) { this.renderInfos.push(info); - if (!this.isRendering) { + if (!this.isRendering && startRendering) { this.render(); } } + start() { + this.render(); + } + setHandler(handler: (info: RenderInfo) => void) { this.handler = handler; } + setOnEndReached(onEndReached: (() => void) | undefined) { + this.onEndReached = onEndReached; + } + cancel() { + this.isRendering = false; if (this.timeout == null) { return; } @@ -35,12 +46,13 @@ class RenderTaskQueue { private render() { const info = this.renderInfos.shift(); if (!info) { + this.onEndReached?.(); this.isRendering = false; return; } this.isRendering = true; - this.handler(info); + this.handler?.(info); this.timeout = setTimeout(() => { this.render(); diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 0fc0133d0aef..8ae4c9310dee 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -7,6 +7,8 @@ import CONST from '@src/CONST'; import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; +const INITIAL_SCROLL_DELAY = 200; + // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { if (item != null) { @@ -31,7 +33,17 @@ type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' const AUTOSCROLL_TO_TOP_THRESHOLD = 250; function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, onInitiallyLoaded, ...rest} = props; + const { + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + renderItem, + keyExtractor = defaultKeyExtractor, + onInitiallyLoaded, + initialNumToRender = 10, + ...rest + } = props; // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more // previous items, until everything is rendered. We also progressively render new data that is added at the start of the @@ -45,49 +57,24 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa const [isInitialData, setIsInitialData] = useState(true); const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const isMessageWithinMinItemsToRender = currentDataIndex > Math.max(0, data.length - CONST.MIN_ITEMS_TO_RENDER); - const displayedData = useMemo(() => { + const {displayedData, negativeScrollIndex} = useMemo(() => { if (currentDataIndex <= 0) { - return data; - } - - if (isMessageWithinMinItemsToRender) { - return data.slice(-CONST.MIN_ITEMS_TO_RENDER); - } - - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE))); - }, [currentDataIndex, data, isInitialData, isMessageWithinMinItemsToRender]); - - const listRef = useRef(null); - - // Whether we should or should not scroll to the top and whether we have already scrolled to the top. - const [initialScrollState, setInitialScrollState] = useState<'shouldScroll' | 'shouldNotScroll' | 'done'>(isMessageWithinMinItemsToRender ? 'shouldScroll' : 'shouldNotScroll'); - - // If the first message becomes unread later and we haven't scrolled to the top yet, we want to scroll to the top. - useEffect(() => { - if (initialScrollState !== 'shouldNotScroll' || !isMessageWithinMinItemsToRender) { - return; + return {displayedData: data, negativeScrollIndex: data.length}; } - setInitialScrollState('shouldScroll'); - }, [currentDataIndex, initialScrollState, isMessageWithinMinItemsToRender]); - useEffect(() => { - // Scroll to the end once the initial data is loaded. - if (isInitialData || initialScrollState === 'done') { - return; - } - - if (initialScrollState === 'shouldScroll') { - listRef.current?.scrollToEnd({animated: false}); - } + const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE)); + const minInitialIndex = Math.max(0, data.length - initialNumToRender); + return { + displayedData: data.slice(Math.min(itemIndex, minInitialIndex)), + negativeScrollIndex: Math.min(data.length, data.length - itemIndex), + }; + }, [currentDataIndex, data, initialNumToRender, isInitialData]); + const initialNegativeScrollIndex = useRef(negativeScrollIndex); - onInitiallyLoaded?.(); - setInitialScrollState('done'); - }, [data.length, displayedData.length, initialScrollState, isInitialData, onInitiallyLoaded]); + const listRef = useRef<(RNFlatList & HTMLElement) | null>(null); - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; + const isMessageOnFirstPage = currentDataIndex > Math.max(0, data.length - initialNumToRender); + const [shouldScrollInitially, setShouldScrollInitially] = useState(isMessageOnFirstPage); // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(), []); @@ -97,28 +84,30 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa }; }, [renderQueue]); - const handleRender = useCallback( - (info: RenderInfo) => { - if (!isLoadingData) { - onStartReached?.(info); - } + const isLoadingData = data.length > displayedData.length; + const wasLoadingData = usePrevious(isLoadingData); + const dataIndexDifference = data.length - displayedData.length; - if (isInitialData) { - console.log('set is initial data to false'); - setIsInitialData(false); - } - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); - }, - [isLoadingData, isInitialData, displayedData, keyExtractor, currentDataIndex, onStartReached], - ); - renderQueue.setHandler(handleRender); + renderQueue.setHandler((info: RenderInfo) => { + if (!isLoadingData) { + onStartReached?.(info); + } + + if (isInitialData) { + setIsInitialData(false); + } + + onInitiallyLoaded?.(); + + const firstDisplayedItem = displayedData.at(0); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); + }); const handleStartReached = useCallback( (info: RenderInfo) => { - renderQueue.add(info); + renderQueue.add(info, !shouldScrollInitially); }, - [renderQueue], + [shouldScrollInitially, renderQueue], ); const handleRenderItem = useCallback( @@ -142,6 +131,19 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa return config; }, [shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + useEffect(() => { + if (!shouldScrollInitially) { + return; + } + + // Scroll to the end once the content is measured and the data is loaded + setTimeout(() => { + listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); + setShouldScrollInitially(false); + renderQueue.start(); + }, INITIAL_SCROLL_DELAY); + }, [displayedData.length, shouldScrollInitially, isInitialData, onInitiallyLoaded, renderQueue]); + useImperativeHandle(ref, () => { // If we're trying to scroll at the start of the list we need to make sure to // render all items. @@ -194,6 +196,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa maintainVisibleContentPosition={maintainVisibleContentPosition} inverted data={displayedData} + initialNumToRender={initialNumToRender} onStartReached={handleStartReached} renderItem={handleRenderItem} keyExtractor={keyExtractor} diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 310eb405ea42..d5d7fcb5d42f 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -344,8 +344,12 @@ function ReportActionsList({ prevReportID = report.reportID; }, [report.reportID]); + const initialScrollKey = useMemo(() => { + return linkedReportActionID ?? unreadMarkerReportActionID; + }, [linkedReportActionID, unreadMarkerReportActionID]); + const [isScrolledToStart, setIsScrolledToStart] = useState(true); + const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); - const [isScrolledToStart, setIsScrolledToStart] = useState(false); useEffect(() => { if (report.reportID !== prevReportID) { return; @@ -743,7 +747,7 @@ function ReportActionsList({ extraData={extraData} key={listID} shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScrollToTopThreshold} - initialScrollKey={reportActionID ?? unreadMarkerReportActionID} + initialScrollKey={initialScrollKey} /> From 5b7d8ce8dbcafc002f22ebb4689f1faba641dffb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Apr 2025 23:09:06 +0200 Subject: [PATCH 019/216] fix: only start render queue when it's not already rendering --- .../InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx index c1b3b594c43d..35f688302615 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx @@ -24,6 +24,9 @@ class RenderTaskQueue { } start() { + if (this.isRendering) { + return; + } this.render(); } From 6d7d226195fe6d3303687d81919aca50eae75893 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Apr 2025 23:21:44 +0200 Subject: [PATCH 020/216] fix: initial loading when offline --- src/pages/home/ReportScreen.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index badf07af253a..8c8247758107 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -740,6 +740,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // only one we will have in cache. const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && reportActions.length <= 1; const isInitiallyLoadingReportWhileOffline = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && isOffline; + const isReportReady = !isInitiallyLoadingReport && !isInitiallyLoadingReportWhileOffline; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. @@ -750,7 +751,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { } // If true reports that are considered MoneyRequest | InvoiceReport will get the new report table view - const shouldDisplayMoneyRequestActionsList = canUseTableReportView && isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, reportTransactions); + const shouldDisplayMoneyRequestActionsList = (canUseTableReportView && isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, reportTransactions)) ?? false; return ( @@ -797,8 +798,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - {(!report || isInitiallyLoadingReport || isInitiallyLoadingReportWhileOffline) && } - {!!report && !shouldDisplayMoneyRequestActionsList ? ( + {(!report || !isReportReady) && } + {!!report && isReportReady && !shouldDisplayMoneyRequestActionsList && ( - ) : null} - {!!report && shouldDisplayMoneyRequestActionsList ? ( + )} + {!!report && isReportReady && shouldDisplayMoneyRequestActionsList && ( - ) : null} - {isCurrentReportLoadedFromOnyx ? ( + )} + {isCurrentReportLoadedFromOnyx && ( - ) : null} + )} From 9277cd8fb078a4470612ec6d6c44054199bbcd08 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Apr 2025 23:34:39 +0200 Subject: [PATCH 021/216] fix: mock new RenderTaskQueue function --- jest/setup.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jest/setup.ts b/jest/setup.ts index 1adc7bc86979..bb793227757e 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -137,6 +137,8 @@ jest.mock( this.handler(info); } + start() {} + setHandler(handler: () => void) { this.handler = handler; } From a5f23385abb73ea6d0c12dac653b0420b8848d79 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 29 Apr 2025 23:36:53 +0200 Subject: [PATCH 022/216] fix: RenderTaskQueue mock --- jest/setup.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index bb793227757e..04a6a8361ed9 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -131,7 +131,9 @@ jest.mock( '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue', () => class SyncRenderTaskQueue { - private handler: (info: unknown) => void = () => {}; + private handler: ((info: unknown) => void) | undefined = undefined; + + private onEndReached: (() => void) | undefined = undefined; add(info: unknown) { this.handler(info); @@ -139,10 +141,14 @@ jest.mock( start() {} - setHandler(handler: () => void) { + setHandler(handler: (info: unknown) => void) { this.handler = handler; } + setOnEndReached(onEndReached: (() => void) | undefined) { + this.onEndReached = onEndReached; + } + cancel() {} }, ); From 0d2e982f64d091c314edc21da6f0dc800fe95bd0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sun, 18 May 2025 22:33:35 +0200 Subject: [PATCH 023/216] fix: initial scrolling in BaseInvertedFlatList --- .../BaseInvertedFlatList/index.tsx | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index c932230c7acb..295fa998111b 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,5 +1,6 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import {InteractionManager} from 'react-native'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; import usePrevious from '@hooks/usePrevious'; @@ -41,6 +42,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa renderItem, keyExtractor = defaultKeyExtractor, onInitiallyLoaded, + onContentSizeChange, initialNumToRender = 10, ...rest } = props; @@ -73,9 +75,6 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa const listRef = useRef<(RNFlatList & HTMLElement) | null>(null); - const isMessageOnFirstPage = currentDataIndex > Math.max(0, data.length - initialNumToRender); - const [shouldScrollInitially, setShouldScrollInitially] = useState(isMessageOnFirstPage); - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(), []); useEffect(() => { @@ -84,6 +83,34 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa }; }, [renderQueue]); + // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded + const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); + const didScroll = useRef(false); + const [hasInitialContentBeenRendered, setHasInitialContentBeenRendered] = useState(false); + + const handleContentSizeChange = useCallback( + (contentWidth: number, contentHeight: number) => { + onContentSizeChange?.(contentWidth, contentHeight); + setHasInitialContentBeenRendered(true); + }, + [onContentSizeChange], + ); + + useEffect(() => { + if (didScroll.current || !isMessageOnFirstPage.current || !hasInitialContentBeenRendered) { + return; + } + + listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); + + // We need to wait for a few milliseconds until the scrolling is done, + // before we start rendering additional items in the list. + setTimeout(() => { + didScroll.current = true; + renderQueue.start(); + }, INITIAL_SCROLL_DELAY); + }, [currentDataIndex, data.length, displayedData.length, hasInitialContentBeenRendered, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue]); + const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); const dataIndexDifference = data.length - displayedData.length; @@ -105,9 +132,10 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa const handleStartReached = useCallback( (info: RenderInfo) => { - renderQueue.add(info, !shouldScrollInitially); + const startRendering = didScroll.current || !isMessageOnFirstPage.current; + renderQueue.add(info, startRendering); }, - [shouldScrollInitially, renderQueue], + [renderQueue], ); const handleRenderItem = useCallback( @@ -131,19 +159,6 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa return config; }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - useEffect(() => { - if (!shouldScrollInitially) { - return; - } - - // Scroll to the end once the content is measured and the data is loaded - setTimeout(() => { - listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); - setShouldScrollInitially(false); - renderQueue.start(); - }, INITIAL_SCROLL_DELAY); - }, [displayedData.length, shouldScrollInitially, isInitialData, onInitiallyLoaded, renderQueue]); - useImperativeHandle(ref, () => { // If we're trying to scroll at the start of the list we need to make sure to // render all items. @@ -198,6 +213,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa data={displayedData} initialNumToRender={initialNumToRender} onStartReached={handleStartReached} + onContentSizeChange={handleContentSizeChange} renderItem={handleRenderItem} keyExtractor={keyExtractor} /> From a3bda66e68ca9fba0dee9eb2ea9b88cacafd63a3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sun, 18 May 2025 22:33:42 +0200 Subject: [PATCH 024/216] fix: TS error in jest test setup --- jest/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest/setup.ts b/jest/setup.ts index 04a6a8361ed9..8148b2b159bc 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -136,7 +136,7 @@ jest.mock( private onEndReached: (() => void) | undefined = undefined; add(info: unknown) { - this.handler(info); + this.handler?.(info); } start() {} From 7d7f0a36c85c667dd0963f29ea507540ca3d6d9b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sun, 18 May 2025 22:38:25 +0200 Subject: [PATCH 025/216] fix: only mark report as read when no newer messages --- src/pages/home/report/ReportActionsList.tsx | 18 +++++++++++------- src/pages/home/report/ReportActionsView.tsx | 1 + .../useReportUnreadMessageScrollTracking.ts | 6 +++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index f731374b8b06..798515b25404 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -99,6 +99,9 @@ type ReportActionsListProps = { /** Function to load newer chats */ loadNewerChats: (force?: boolean) => void; + /** Whether the report has newer actions to load */ + hasNewerActions: boolean; + /** Whether the composer is in full size */ isComposerFullSize?: boolean; @@ -142,6 +145,7 @@ function ReportActionsList({ mostRecentIOUReportActionID = '', loadNewerChats, loadOlderChats, + hasNewerActions, onLayout, isComposerFullSize, listID, @@ -343,6 +347,7 @@ function ReportActionsList({ readActionSkippedRef: readActionSkipped, hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, onTrackScrolling: trackScrolling, + hasNewerActions, }); useEffect(() => { @@ -381,22 +386,21 @@ function ReportActionsList({ return; } - if (isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction) && isListInitiallyLoaded)) { + if (isListInitiallyLoaded && (isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction)))) { // On desktop, when the notification center is displayed, isVisible will return false. // 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) && isScrolledToStart) { + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToStart) { readNewestAction(report.reportID); if (isFromNotification) { Navigation.setParams({referrer: undefined}); } - } else { - readActionSkipped.current = true; + return; } - } else { - readActionSkipped.current = true; } + + readActionSkipped.current = true; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, isScrolledToStart, isListInitiallyLoaded]); @@ -555,7 +559,7 @@ function ReportActionsList({ }, [parentReportAction, report, sortedVisibleReportActions]); useEffect(() => { - if (report.reportID !== prevReportID) { + if (report.reportID !== prevReportID || !isListInitiallyLoaded) { return; } diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 2924718fa36a..ae6f15788b85 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -296,6 +296,7 @@ function ReportActionsView({ mostRecentIOUReportActionID={mostRecentIOUReportActionID} loadOlderChats={loadOlderChats} loadNewerChats={loadNewerChats} + hasNewerActions={hasNewerActions} listID={listID} shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScroll} /> diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts index 76cd1ea43f7f..2ea86b5137af 100644 --- a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -20,6 +20,9 @@ type Args = { /** Whether the unread marker is displayed for any report action */ hasUnreadMarkerReportAction: boolean; + /** Whether the report has newer actions to load */ + hasNewerActions: boolean; + /** Callback to call on every scroll event */ onTrackScrolling: (event: NativeSyntheticEvent) => void; }; @@ -29,6 +32,7 @@ export default function useReportUnreadMessageScrollTracking({ currentVerticalScrollingOffsetRef, floatingMessageVisibleInitialValue, hasUnreadMarkerReportAction, + hasNewerActions, readActionSkippedRef, onTrackScrolling, }: Args) { @@ -49,7 +53,7 @@ export default function useReportUnreadMessageScrollTracking({ // hide floating button if we're scrolled closer than the offset and mark message as read if (currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) { - if (readActionSkippedRef.current) { + if (readActionSkippedRef.current && !hasNewerActions) { // eslint-disable-next-line react-compiler/react-compiler,no-param-reassign readActionSkippedRef.current = false; readNewestAction(reportID); From 0df5d066e743fb73633b33596b98af7cc72d914b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sun, 18 May 2025 22:38:35 +0200 Subject: [PATCH 026/216] fix: allow fetching new/old messages multiple times --- src/hooks/useLoadReportActions.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 00b395277ef2..185974237452 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useMemo, useRef} from 'react'; +import {useCallback, useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {getNewerActions, getOlderActions} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -32,9 +32,6 @@ type UseLoadReportActionsArguments = { * Used in the report displaying components */ function useLoadReportActions({reportID, reportActions, allReportActionIDs, transactionThreadReport, hasOlderActions, hasNewerActions}: UseLoadReportActionsArguments) { - const didLoadOlderChats = useRef(false); - const didLoadNewerChats = useRef(false); - const {isOffline} = useNetwork(); const isFocused = useIsFocused(); @@ -64,8 +61,6 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran return; } - didLoadOlderChats.current = true; - if (!isEmptyObject(transactionThreadReport)) { // Get older actions based on the oldest reportAction for the current report const oldestActionCurrentReport = reportActionIDMap.findLast((item) => item.reportID === reportID); @@ -92,14 +87,11 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran isOffline || // If there was an error only try again once on initial mount. We should also still load // more in case we have cached messages. - didLoadNewerChats.current || newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) ) { return; } - didLoadNewerChats.current = true; - // If this is a one transaction report, ensure we load newer actions for both this report and the report associated with the transaction if (!isEmptyObject(transactionThreadReport)) { // Get newer actions based on the newest reportAction for the current report From 7ab75e825726e380238a14b067a53ac5b4d5a72e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 20 May 2025 12:04:36 +0200 Subject: [PATCH 027/216] fix: TS and ESLint errors --- .../InvertedFlatList/BaseInvertedFlatList/index.tsx | 1 - tests/unit/useReportUnreadMessageScrollTrackingTest.ts | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 295fa998111b..df45f731bea4 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,6 +1,5 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {InteractionManager} from 'react-native'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; import usePrevious from '@hooks/usePrevious'; diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index 62f7ebda0b4c..a74c1dc245c3 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -29,8 +29,9 @@ describe('useReportUnreadMessageScrollTracking', () => { currentVerticalScrollingOffsetRef: offsetRef, readActionSkippedRef: readActionRefFalse, floatingMessageVisibleInitialValue: false, - hasUnreadMarkerReportAction: false, onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + hasUnreadMarkerReportAction: false, }), ); @@ -49,6 +50,7 @@ describe('useReportUnreadMessageScrollTracking', () => { readActionSkippedRef: readActionRefFalse, floatingMessageVisibleInitialValue: false, hasUnreadMarkerReportAction: false, + hasNewerActions: false, onTrackScrolling: onTrackScrollingMockFn, }), ); @@ -78,6 +80,7 @@ describe('useReportUnreadMessageScrollTracking', () => { readActionSkippedRef: readActionRefFalse, floatingMessageVisibleInitialValue: false, hasUnreadMarkerReportAction: true, + hasNewerActions: false, onTrackScrolling: onTrackScrollingMockFn, }), ); @@ -104,6 +107,7 @@ describe('useReportUnreadMessageScrollTracking', () => { readActionSkippedRef: readActionRefFalse, floatingMessageVisibleInitialValue: false, hasUnreadMarkerReportAction: true, + hasNewerActions: false, onTrackScrolling: onTrackScrollingMockFn, }), ); @@ -130,6 +134,7 @@ describe('useReportUnreadMessageScrollTracking', () => { readActionSkippedRef: {current: true}, floatingMessageVisibleInitialValue: false, hasUnreadMarkerReportAction: true, + hasNewerActions: false, onTrackScrolling: onTrackScrollingMockFn, }), ); From f28a353732d1d8583e4900feb82780f3c2dbc2d8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 20 May 2025 12:41:56 +0200 Subject: [PATCH 028/216] fix: more TS errors --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 1 + tests/perf-test/ReportActionsList.perf-test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index cd2846451d8a..863271a65524 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -267,6 +267,7 @@ function MoneyRequestReportActionsList({report, policy, reportActions = [], tran floatingMessageVisibleInitialValue: false, readActionSkippedRef: readActionSkipped, hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, + hasNewerActions, onTrackScrolling: (event: NativeSyntheticEvent) => { const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; const fullContentHeight = contentSize.height; diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 42026e63f6a8..7640dcaaf023 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -120,6 +120,7 @@ function ReportActionsListWrapper() { listID={1} loadOlderChats={mockLoadChats} loadNewerChats={mockLoadChats} + hasNewerActions={false} transactionThreadReport={report} /> From 425244268ee6d74e5ce2743c4336e4db1bd447a8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 12:47:12 +0200 Subject: [PATCH 029/216] fix: unread marker --- src/pages/home/report/ReportActionsList.tsx | 38 +++++++++++-------- .../useReportUnreadMessageScrollTracking.ts | 14 +++---- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 59c1b0080fe0..e0b8185c5cad 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -323,21 +323,11 @@ function ReportActionsList({ const indexOfLinkedAction = reportActionID ? sortedVisibleReportActions.findIndex((action) => action.reportActionID === reportActionID) : -1; const isLinkedActionCloseToNewest = indexOfLinkedAction < IS_CLOSE_TO_NEWEST_THRESHOLD; - const [isScrolledToStart, setIsScrolledToStart] = useState(true); const trackScrolling = (event: NativeSyntheticEvent) => { scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; onScroll?.(event); scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; - - // Once we hit the start of the list, we want to trigger the read last message logic, if the message is unread - if (scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD) { - if (!isScrolledToStart) { - setIsScrolledToStart(true); - } - } else if (isScrolledToStart) { - setIsScrolledToStart(false); - } }; const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling} = useReportUnreadMessageScrollTracking({ @@ -345,7 +335,6 @@ function ReportActionsList({ currentVerticalScrollingOffsetRef: scrollingVerticalOffset, floatingMessageVisibleInitialValue: !isLinkedActionCloseToNewest, readActionSkippedRef: readActionSkipped, - hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, onTrackScrolling: trackScrolling, hasNewerActions, }); @@ -381,17 +370,36 @@ function ReportActionsList({ }, [linkedReportActionID, unreadMarkerReportActionID]); const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); + + const isReportUnread = useMemo( + () => isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction)), + [report, transactionThreadReport, lastAction], + ); + + // Mark the report as read when the user initially opens the report + const didMarkReportAsUnreadOnOpen = useRef(false); + useEffect(() => { + if (!isListInitiallyLoaded || !isReportUnread || didMarkReportAsUnreadOnOpen.current) { + return; + } + + readNewestAction(report.reportID); + didMarkReportAsUnreadOnOpen.current = true; + }, [isListInitiallyLoaded, isReportUnread, report.reportID]); + useEffect(() => { if (report.reportID !== prevReportID) { return; } - if (isListInitiallyLoaded && (isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction)))) { + if (isListInitiallyLoaded && isReportUnread) { // On desktop, when the notification center is displayed, isVisible will return false. // 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) && !hasNewerActions && isScrolledToStart) { + const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; + + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { readNewestAction(report.reportID); if (isFromNotification) { Navigation.setParams({referrer: undefined}); @@ -402,7 +410,7 @@ function ReportActionsList({ readActionSkipped.current = true; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, isScrolledToStart, isListInitiallyLoaded]); + }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, isListInitiallyLoaded, hasNewerActions]); useEffect(() => { if (linkedReportActionID || unreadMarkerReportActionID) { @@ -432,7 +440,6 @@ function ReportActionsList({ const scrollToBottomForCurrentUserAction = useCallback( (isFromCurrentUser: boolean) => { InteractionManager.runAfterInteractions(() => { - setIsFloatingMessageCounterVisible(false); // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where // they are now in the list. if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { @@ -448,6 +455,7 @@ function ReportActionsList({ return; } + setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); setIsScrollToBottomEnabled(true); }); diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts index 2ea86b5137af..8ece90af3ae9 100644 --- a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -17,9 +17,6 @@ type Args = { /** The initial value for visibility of floating message button */ floatingMessageVisibleInitialValue: boolean; - /** Whether the unread marker is displayed for any report action */ - hasUnreadMarkerReportAction: boolean; - /** Whether the report has newer actions to load */ hasNewerActions: boolean; @@ -31,7 +28,6 @@ export default function useReportUnreadMessageScrollTracking({ reportID, currentVerticalScrollingOffsetRef, floatingMessageVisibleInitialValue, - hasUnreadMarkerReportAction, hasNewerActions, readActionSkippedRef, onTrackScrolling, @@ -46,14 +42,16 @@ export default function useReportUnreadMessageScrollTracking({ const trackVerticalScrolling = (event: NativeSyntheticEvent) => { onTrackScrolling(event); - // display floating button if we're scrolled more than the offset - if (currentVerticalScrollingOffsetRef.current > CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && hasUnreadMarkerReportAction) { + const isScrolledToEnd = currentVerticalScrollingOffsetRef.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; + + // Display floating button if we're scrolled more than the offset + if (!isScrolledToEnd && !isFloatingMessageCounterVisible) { setIsFloatingMessageCounterVisible(true); } // hide floating button if we're scrolled closer than the offset and mark message as read - if (currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) { - if (readActionSkippedRef.current && !hasNewerActions) { + if (isScrolledToEnd && !hasNewerActions && isFloatingMessageCounterVisible) { + if (readActionSkippedRef.current) { // eslint-disable-next-line react-compiler/react-compiler,no-param-reassign readActionSkippedRef.current = false; readNewestAction(reportID); From b2e35b93335722d90ce0679933d2e5d976e51388 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 12:54:13 +0200 Subject: [PATCH 030/216] fix: only show floating message counter when we have an unread message --- src/pages/home/report/ReportActionsList.tsx | 1 + .../home/report/useReportUnreadMessageScrollTracking.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index e0b8185c5cad..0b7e3a0eb461 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -335,6 +335,7 @@ function ReportActionsList({ currentVerticalScrollingOffsetRef: scrollingVerticalOffset, floatingMessageVisibleInitialValue: !isLinkedActionCloseToNewest, readActionSkippedRef: readActionSkipped, + hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, onTrackScrolling: trackScrolling, hasNewerActions, }); diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts index 8ece90af3ae9..01d54a07b192 100644 --- a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -17,6 +17,9 @@ type Args = { /** The initial value for visibility of floating message button */ floatingMessageVisibleInitialValue: boolean; + /** Whether the unread marker is displayed for any report action */ + hasUnreadMarkerReportAction: boolean; + /** Whether the report has newer actions to load */ hasNewerActions: boolean; @@ -28,6 +31,7 @@ export default function useReportUnreadMessageScrollTracking({ reportID, currentVerticalScrollingOffsetRef, floatingMessageVisibleInitialValue, + hasUnreadMarkerReportAction, hasNewerActions, readActionSkippedRef, onTrackScrolling, @@ -44,8 +48,8 @@ export default function useReportUnreadMessageScrollTracking({ const isScrolledToEnd = currentVerticalScrollingOffsetRef.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; - // Display floating button if we're scrolled more than the offset - if (!isScrolledToEnd && !isFloatingMessageCounterVisible) { + // When we have an unread message, display floating button if we're scrolled more than the offset + if (hasUnreadMarkerReportAction && !isScrolledToEnd && !isFloatingMessageCounterVisible) { setIsFloatingMessageCounterVisible(true); } From 6080aecce0575797b769deadb849c16b678f1d0d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 13:50:00 +0200 Subject: [PATCH 031/216] fix: prevent multiple calls to GetOlderActions/GetNewerActions at the same time --- src/hooks/useLoadReportActions.ts | 39 ++++++++++++++++++++++++------- src/libs/API/index.ts | 12 ++++------ src/libs/actions/Report.ts | 8 +++---- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 185974237452..7e610b140f39 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -1,9 +1,9 @@ import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useMemo} from 'react'; +import {useCallback, useMemo, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {getNewerActions, getOlderActions} from '@userActions/Report'; import CONST from '@src/CONST'; -import type {Report, ReportAction} from '@src/types/onyx'; +import type {Report, ReportAction, Response} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import useNetwork from './useNetwork'; @@ -32,9 +32,19 @@ type UseLoadReportActionsArguments = { * Used in the report displaying components */ function useLoadReportActions({reportID, reportActions, allReportActionIDs, transactionThreadReport, hasOlderActions, hasNewerActions}: UseLoadReportActionsArguments) { + const didLoadNewerChats = useRef(false); + const didLoadOlderChats = useRef(false); + + const resetDidLoadOlderChats = () => { + didLoadOlderChats.current = false; + }; + + const resetDidLoadNewerChats = () => { + didLoadNewerChats.current = false; + }; + const {isOffline} = useNetwork(); const isFocused = useIsFocused(); - const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); @@ -61,18 +71,23 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran return; } + didLoadOlderChats.current = true; + const getOlderActionsPromises: Array> = []; + if (!isEmptyObject(transactionThreadReport)) { // Get older actions based on the oldest reportAction for the current report const oldestActionCurrentReport = reportActionIDMap.findLast((item) => item.reportID === reportID); - getOlderActions(oldestActionCurrentReport?.reportID, oldestActionCurrentReport?.reportActionID); + getOlderActionsPromises.push(getOlderActions(oldestActionCurrentReport?.reportID, oldestActionCurrentReport?.reportActionID)); // Get older actions based on the oldest reportAction for the transaction thread report const oldestActionTransactionThreadReport = reportActionIDMap.findLast((item) => item.reportID === transactionThreadReport.reportID); - getOlderActions(oldestActionTransactionThreadReport?.reportID, oldestActionTransactionThreadReport?.reportActionID); + getOlderActionsPromises.push(getOlderActions(oldestActionTransactionThreadReport?.reportID, oldestActionTransactionThreadReport?.reportActionID)); } else { // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments - getOlderActions(reportID, oldestReportAction.reportActionID); + getOlderActionsPromises.push(getOlderActions(reportID, oldestReportAction.reportActionID)); } + + Promise.all(getOlderActionsPromises).then(resetDidLoadOlderChats); }, [isOffline, oldestReportAction, reportID, reportActionIDMap, transactionThreadReport, hasOlderActions], ); @@ -92,18 +107,24 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran return; } + didLoadNewerChats.current = true; + const getNewerActionsPromises: Array> = []; + // If this is a one transaction report, ensure we load newer actions for both this report and the report associated with the transaction if (!isEmptyObject(transactionThreadReport)) { // Get newer actions based on the newest reportAction for the current report const newestActionCurrentReport = reportActionIDMap.find((item) => item.reportID === reportID); - getNewerActions(newestActionCurrentReport?.reportID, newestActionCurrentReport?.reportActionID); + + getNewerActionsPromises.push(getNewerActions(newestActionCurrentReport?.reportID, newestActionCurrentReport?.reportActionID)); // Get newer actions based on the newest reportAction for the transaction thread report const newestActionTransactionThreadReport = reportActionIDMap.find((item) => item.reportID === transactionThreadReport.reportID); - getNewerActions(newestActionTransactionThreadReport?.reportID, newestActionTransactionThreadReport?.reportActionID); + getNewerActionsPromises.push(getNewerActions(newestActionTransactionThreadReport?.reportID, newestActionTransactionThreadReport?.reportActionID)); } else if (newestReportAction) { - getNewerActions(reportID, newestReportAction.reportActionID); + getNewerActionsPromises.push(getNewerActions(reportID, newestReportAction.reportActionID)); } + + Promise.all(getNewerActionsPromises).then(resetDidLoadNewerChats); }, [isFocused, newestReportAction, hasNewerActions, isOffline, transactionThreadReport, reportActionIDMap, reportID], ); diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 0ba6391d21bc..7d196819e514 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -234,7 +234,7 @@ function paginate; function paginate>( type: TRequestType, command: TCommand, @@ -242,7 +242,7 @@ function paginate; function paginate>( type: TRequestType, command: TCommand, @@ -250,7 +250,7 @@ function paginate | void { +): Promise { Log.info('[API] Called API.paginate', false, {command, ...apiCommandParameters}); const request: PaginatedRequest = { ...prepareRequest(command, type, apiCommandParameters, onyxData, conflictResolver), @@ -262,13 +262,11 @@ function paginate processRequest(request, type)); - return; + return waitForWrites(command as ReadCommand).then(() => processRequest(request, type)); default: throw new Error('Unknown API request type'); } diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 0ff4a5fc42e3..78428daf2e55 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1462,7 +1462,7 @@ function navigateToAndOpenChildReport(childReportID: string | undefined, parentR */ function getOlderActions(reportID: string | undefined, reportActionID: string | undefined) { if (!reportID || !reportActionID) { - return; + return Promise.resolve(); } const optimisticData: OnyxUpdate[] = [ @@ -1502,7 +1502,7 @@ function getOlderActions(reportID: string | undefined, reportActionID: string | reportActionID, }; - API.paginate( + return API.paginate( CONST.API_REQUEST_TYPE.READ, READ_COMMANDS.GET_OLDER_ACTIONS, parameters, @@ -1520,7 +1520,7 @@ function getOlderActions(reportID: string | undefined, reportActionID: string | */ function getNewerActions(reportID: string | undefined, reportActionID: string | undefined) { if (!reportID || !reportActionID) { - return; + return Promise.resolve(); } const optimisticData: OnyxUpdate[] = [ @@ -1560,7 +1560,7 @@ function getNewerActions(reportID: string | undefined, reportActionID: string | reportActionID, }; - API.paginate( + return API.paginate( CONST.API_REQUEST_TYPE.READ, READ_COMMANDS.GET_NEWER_ACTIONS, parameters, From 40c5a9209012f52e2c75be532ac913598e5469f3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 13:50:25 +0200 Subject: [PATCH 032/216] fix: prevent readNewestAction when report has not been unread --- src/pages/home/report/ReportActionsList.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 0b7e3a0eb461..0931fa4c4a6f 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -377,15 +377,20 @@ function ReportActionsList({ [report, transactionThreadReport, lastAction], ); - // Mark the report as read when the user initially opens the report - const didMarkReportAsUnreadOnOpen = useRef(false); + // Mark the report as read when the user initially opens the report and there are unread messages + const didMarkReportReadInitially = useRef(false); useEffect(() => { - if (!isListInitiallyLoaded || !isReportUnread || didMarkReportAsUnreadOnOpen.current) { + if (!isListInitiallyLoaded) { return; } + if (!isReportUnread || didMarkReportReadInitially.current) { + didMarkReportReadInitially.current = true; + return; + } + + didMarkReportReadInitially.current = true; readNewestAction(report.reportID); - didMarkReportAsUnreadOnOpen.current = true; }, [isListInitiallyLoaded, isReportUnread, report.reportID]); useEffect(() => { From ddfcf160f90f97ee147f26f85f66e92075d6ae1d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 14:46:09 +0200 Subject: [PATCH 033/216] fix: better variable names --- src/hooks/useLoadReportActions.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 7e610b140f39..8809dda0c2c0 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -32,15 +32,15 @@ type UseLoadReportActionsArguments = { * Used in the report displaying components */ function useLoadReportActions({reportID, reportActions, allReportActionIDs, transactionThreadReport, hasOlderActions, hasNewerActions}: UseLoadReportActionsArguments) { - const didLoadNewerChats = useRef(false); - const didLoadOlderChats = useRef(false); + const isLoadingNewerChats = useRef(false); + const isLoadingOlderChats = useRef(false); - const resetDidLoadOlderChats = () => { - didLoadOlderChats.current = false; + const resetIsLoadingOlderChats = () => { + isLoadingOlderChats.current = false; }; - const resetDidLoadNewerChats = () => { - didLoadNewerChats.current = false; + const resetIsLoadingNewerChats = () => { + isLoadingNewerChats.current = false; }; const {isOffline} = useNetwork(); @@ -62,7 +62,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran const loadOlderChats = useCallback( (force = false) => { // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. - if (!force && isOffline) { + if (!force && (isOffline || isLoadingOlderChats.current)) { return; } @@ -71,7 +71,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran return; } - didLoadOlderChats.current = true; + isLoadingOlderChats.current = true; const getOlderActionsPromises: Array> = []; if (!isEmptyObject(transactionThreadReport)) { @@ -87,7 +87,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran getOlderActionsPromises.push(getOlderActions(reportID, oldestReportAction.reportActionID)); } - Promise.all(getOlderActionsPromises).then(resetDidLoadOlderChats); + Promise.all(getOlderActionsPromises).then(resetIsLoadingOlderChats); }, [isOffline, oldestReportAction, reportID, reportActionIDMap, transactionThreadReport, hasOlderActions], ); @@ -100,6 +100,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran !newestReportAction || !hasNewerActions || isOffline || + isLoadingNewerChats.current || // If there was an error only try again once on initial mount. We should also still load // more in case we have cached messages. newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) @@ -107,7 +108,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran return; } - didLoadNewerChats.current = true; + isLoadingNewerChats.current = true; const getNewerActionsPromises: Array> = []; // If this is a one transaction report, ensure we load newer actions for both this report and the report associated with the transaction @@ -124,7 +125,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran getNewerActionsPromises.push(getNewerActions(reportID, newestReportAction.reportActionID)); } - Promise.all(getNewerActionsPromises).then(resetDidLoadNewerChats); + Promise.all(getNewerActionsPromises).then(resetIsLoadingNewerChats); }, [isFocused, newestReportAction, hasNewerActions, isOffline, transactionThreadReport, reportActionIDMap, reportID], ); From bbba7cad2ea14562b508eb9af110f9df1fdc4b20 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 14:46:18 +0200 Subject: [PATCH 034/216] fix: update PaginationTest accordingly --- tests/ui/PaginationTest.tsx | 68 +++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index b24acc14e1e5..6385331469bb 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -6,6 +6,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import {setSidebarLoaded} from '@libs/actions/App'; import {subscribeToUserEvents} from '@libs/actions/User'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {translateLocal} from '@libs/Localize'; import {waitForIdle} from '@libs/Network/SequentialQueue'; import App from '@src/App'; @@ -77,7 +78,7 @@ function buildReportComments(count: number, initialID: string, reverse = false) } function mockOpenReport(messageCount: number, initialID: string) { - fetchMock.mockAPICommand('OpenReport', ({reportID, reportActionID}) => { + fetchMock.mockAPICommand(WRITE_COMMANDS.OPEN_REPORT, ({reportID, reportActionID}) => { const comments = buildReportComments(messageCount, initialID); return { onyxData: @@ -97,7 +98,7 @@ function mockOpenReport(messageCount: number, initialID: string) { } function mockGetOlderActions(messageCount: number) { - fetchMock.mockAPICommand('GetOlderActions', ({reportID, reportActionID}) => { + fetchMock.mockAPICommand(READ_COMMANDS.GET_OLDER_ACTIONS, ({reportID, reportActionID}) => { // The API also returns the action that was requested with the reportActionID. const comments = buildReportComments(messageCount + 1, reportActionID); return { @@ -117,7 +118,7 @@ function mockGetOlderActions(messageCount: number) { } function mockGetNewerActions(messageCount: number) { - fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => ({ + fetchMock.mockAPICommand(READ_COMMANDS.GET_NEWER_ACTIONS, ({reportID, reportActionID}) => ({ onyxData: reportID === REPORT_ID ? [ @@ -231,10 +232,10 @@ describe('Pagination', () => { await navigateToSidebarOption(REPORT_ID); expect(getReportActions()).toHaveLength(5); - TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); - TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID}); - TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.OPEN_REPORT, 0, {reportID: REPORT_ID}); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); // Scrolling here should not trigger a new network request. scrollToOffset(LIST_CONTENT_SIZE.height); @@ -242,9 +243,9 @@ describe('Pagination', () => { scrollToOffset(0); await waitForBatchedUpdatesWithAct(); - TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); - TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); }); it('opens a chat and load older messages', async () => { @@ -255,19 +256,19 @@ describe('Pagination', () => { await navigateToSidebarOption(REPORT_ID); expect(getReportActions()).toHaveLength(CONST.REPORT.MIN_INITIAL_REPORT_ACTION_COUNT); - TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); - TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID}); - TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.OPEN_REPORT, 0, {reportID: REPORT_ID}); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); // Scrolling here should trigger a new network request. scrollToOffset(LIST_CONTENT_SIZE.height); await waitForBatchedUpdatesWithAct(); - TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); - TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 1); - TestHelper.expectAPICommandToHaveBeenCalledWith('GetOlderActions', 0, {reportID: REPORT_ID, reportActionID: '4'}); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 1); + TestHelper.expectAPICommandToHaveBeenCalledWith(READ_COMMANDS.GET_OLDER_ACTIONS, 0, {reportID: REPORT_ID, reportActionID: '4'}); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); await waitForBatchedUpdatesWithAct(); @@ -299,10 +300,13 @@ describe('Pagination', () => { expect(getReportActions()).toHaveLength(10); // There is 1 extra call here because of the comment linking report. - TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); - TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 1, {reportID: REPORT_ID, reportActionID: '5'}); - TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalledWith('GetNewerActions', 0, {reportID: REPORT_ID, reportActionID: '5'}); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 3); + TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.OPEN_REPORT, 1, {reportID: REPORT_ID, reportActionID: '5'}); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalledWith(READ_COMMANDS.GET_NEWER_ACTIONS, 0, {reportID: REPORT_ID, reportActionID: '5'}); + + // Simulate the backend returning no new messages to simulate reaching the start of the chat. + mockGetNewerActions(0); // Simulate the maintainVisibleContentPosition scroll adjustment, so it is now possible to scroll down more. scrollToOffset(500); @@ -310,26 +314,24 @@ describe('Pagination', () => { scrollToOffset(0); await waitForBatchedUpdatesWithAct(); - TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); - TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); - // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 3); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 2); scrollToOffset(500); await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); - TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); - TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); + // When there are no newer actions, we don't want to trigger GetNewerActions again. + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 3); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 2); - // We still have 15 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. + // We still have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); - }); + }, 10000000000); }); From bf15082be7d6da1d951a8816daeda45bfb1396cf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 14:47:12 +0200 Subject: [PATCH 035/216] fix: remove test timeout --- tests/ui/PaginationTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 6385331469bb..3d12c0fb6134 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -333,5 +333,5 @@ describe('Pagination', () => { // We still have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); - }, 10000000000); + }); }); From 8c4565c8641f7b6a260df9361f0b1a41b0645234 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 18:43:19 +0200 Subject: [PATCH 036/216] fix: wait for list to emit initial load event before expecting in tests --- src/CONST.ts | 1 + src/pages/home/report/ReportActionsList.tsx | 6 +++++- tests/ui/UnreadIndicatorsTest.tsx | 21 ++++++++++++++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index eca3135bcc18..e123ad684ec3 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5189,6 +5189,7 @@ const CONST = { }, EVENTS: { SCROLLING: 'scrolling', + REPORT_ACTIONS_LIST_INITIALLY_LOADED: 'reportActionsListInitiallyLoaded', }, SELECTION_LIST_WITH_MODAL_TEST_ID: 'selectionListWithModalMenuItem', diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 0931fa4c4a6f..c6f62c954559 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -371,6 +371,10 @@ function ReportActionsList({ }, [linkedReportActionID, unreadMarkerReportActionID]); const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); + const handleListInitiallyLoaded = useCallback(() => { + setIsListInitiallyLoaded(true); + DeviceEventEmitter.emit(CONST.EVENTS.REPORT_ACTIONS_LIST_INITIALLY_LOADED); + }, []); const isReportUnread = useMemo( () => isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction)), @@ -747,7 +751,7 @@ function ReportActionsList({ ListHeaderComponent={listHeaderComponent} ListFooterComponent={listFooterComponent} keyboardShouldPersistTaps="handled" - onInitiallyLoaded={() => setIsListInitiallyLoaded(true)} + onInitiallyLoaded={handleListInitiallyLoaded} onLayout={onLayoutInner} onScroll={trackVerticalScrolling} onScrollToIndexFailed={onScrollToIndexFailed} diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 705194f0c06b..5e803b10f35b 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -5,7 +5,7 @@ import {addSeconds, format, subMinutes, subSeconds} from 'date-fns'; import {toZonedTime} from 'date-fns-tz'; import React from 'react'; import {AppState, DeviceEventEmitter} from 'react-native'; -import type {TextStyle, ViewStyle} from 'react-native'; +import type {EventSubscription, TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {setSidebarLoaded} from '@libs/actions/App'; @@ -230,7 +230,7 @@ describe('Unread Indicators', () => { expect(createdAction).toBeTruthy(); const reportCommentsHintText = translateLocal('accessibilityHints.chatMessage'); const reportComments = screen.queryAllByLabelText(reportCommentsHintText); - expect(reportComments).toHaveLength(4); + expect(reportComments).toHaveLength(9); // Since the last read timestamp is the timestamp of action 3 we should have an unread indicator above the next "unread" action which will // have actionID of 4 const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); @@ -285,7 +285,7 @@ describe('Unread Indicators', () => { expect(unreadIndicator).toHaveLength(0); expect(areYouOnChatListScreen()).toBe(false); })); - it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => + it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => { signInAndGetAppWithUnreadChat() .then(() => { // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant @@ -364,8 +364,17 @@ describe('Unread Indicators', () => { expect((secondReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); expect(screen.getByText('B User')).toBeOnTheScreen(); + let reportActionsListInitialLoadListener: EventSubscription; + const chatListInitialLoadPromise = new Promise((resolve) => { + reportActionsListInitialLoadListener = DeviceEventEmitter.addListener(CONST.EVENTS.REPORT_ACTIONS_LIST_INITIALLY_LOADED, resolve); + }); + // Tap the new report option and navigate back to the sidebar again via the back button - return navigateToSidebarOption(0); + const navigationPromise = navigateToSidebarOption(0); + + return Promise.all([navigationPromise, chatListInitialLoadPromise]).then(() => { + reportActionsListInitialLoadListener.remove(); + }); }) .then(waitForBatchedUpdates) .then(async () => { @@ -373,12 +382,14 @@ describe('Unread Indicators', () => { // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); + expect(displayNameTexts).toHaveLength(2); expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.normal); expect(screen.getAllByText('C User').at(0)).toBeOnTheScreen(); expect((displayNameTexts.at(1)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); expect(screen.getByText('B User', {includeHiddenElements: true})).toBeOnTheScreen(); - })); + }); + }); xit('Manually marking a chat message as unread shows the new line indicator and updates the LHN', () => signInAndGetAppWithUnreadChat() From f4f8db67749f3c92fae2ad3c549f5ff168960e4a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 21:03:51 +0200 Subject: [PATCH 037/216] fix: improve unread indicator test --- .../ReportActionsList/__mocks__/testUtils.ts | 11 ++++++++ .../index.tsx} | 18 +++++++------ .../report/ReportActionsList/testUtils.ts | 9 +++++++ tests/ui/UnreadIndicatorsTest.tsx | 26 +++++++------------ 4 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts rename src/pages/home/report/{ReportActionsList.tsx => ReportActionsList/index.tsx} (97%) create mode 100644 src/pages/home/report/ReportActionsList/testUtils.ts diff --git a/src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts b/src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts new file mode 100644 index 000000000000..d1dfb85afdbe --- /dev/null +++ b/src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts @@ -0,0 +1,11 @@ +import type {OnReportActionListLoadedInTests} from '@pages/home/report/ReportActionsList/testUtils'; + +const NOOP = () => {}; + +// eslint-disable-next-line import/no-mutable-exports +let onReportActionListLoadedInTests: OnReportActionListLoadedInTests = NOOP; +const setOnReportActionListLoadedInTests = (callback: OnReportActionListLoadedInTests) => { + onReportActionListLoadedInTests = callback; +}; + +export {onReportActionListLoadedInTests, setOnReportActionListLoadedInTests}; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList/index.tsx similarity index 97% rename from src/pages/home/report/ReportActionsList.tsx rename to src/pages/home/report/ReportActionsList/index.tsx index c6f62c954559..98b46c4f70f9 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList/index.tsx @@ -50,6 +50,12 @@ import { } from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter'; +import getInitialNumToRender from '@pages/home/report/getInitialNumReportActionsToRender'; +import ListBoundaryLoader from '@pages/home/report/ListBoundaryLoader'; +import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer'; +import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction'; +import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking'; import variables from '@styles/variables'; import {getCurrentUserAccountID, openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import {PersonalDetailsContext} from '@src/components/OnyxProvider'; @@ -58,12 +64,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; 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 ListBoundaryLoader from './ListBoundaryLoader'; -import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; -import shouldDisplayNewMarkerOnReportAction from './shouldDisplayNewMarkerOnReportAction'; -import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; +import {onReportActionListLoadedInTests} from './testUtils'; type ReportActionsListProps = { /** The report currently being looked at */ @@ -373,8 +374,8 @@ function ReportActionsList({ const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); const handleListInitiallyLoaded = useCallback(() => { setIsListInitiallyLoaded(true); - DeviceEventEmitter.emit(CONST.EVENTS.REPORT_ACTIONS_LIST_INITIALLY_LOADED); - }, []); + onReportActionListLoadedInTests(report.reportID); + }, [report.reportID]); const isReportUnread = useMemo( () => isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction)), @@ -768,5 +769,6 @@ function ReportActionsList({ ReportActionsList.displayName = 'ReportActionsList'; export default memo(ReportActionsList); +export {onReportActionListLoadedInTests}; export type {ReportActionsListProps}; diff --git a/src/pages/home/report/ReportActionsList/testUtils.ts b/src/pages/home/report/ReportActionsList/testUtils.ts new file mode 100644 index 000000000000..a1db44dd1560 --- /dev/null +++ b/src/pages/home/report/ReportActionsList/testUtils.ts @@ -0,0 +1,9 @@ +type OnReportActionListLoadedInTests = (reportID: string) => void; + +const NOOP = () => {}; + +const onReportActionListLoadedInTests: OnReportActionListLoadedInTests = NOOP; +const setOnReportActionListLoadedInTests: (callback: OnReportActionListLoadedInTests) => void = NOOP; + +export {onReportActionListLoadedInTests, setOnReportActionListLoadedInTests}; +export type {OnReportActionListLoadedInTests}; diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 5e803b10f35b..8663036ba9d8 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -5,7 +5,7 @@ import {addSeconds, format, subMinutes, subSeconds} from 'date-fns'; import {toZonedTime} from 'date-fns-tz'; import React from 'react'; import {AppState, DeviceEventEmitter} from 'react-native'; -import type {EventSubscription, TextStyle, ViewStyle} from 'react-native'; +import type {TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {setSidebarLoaded} from '@libs/actions/App'; @@ -18,6 +18,7 @@ import {translateLocal} from '@libs/Localize'; import LocalNotification from '@libs/Notification/LocalNotification'; import {rand64} from '@libs/NumberUtils'; import {getReportActionText} from '@libs/ReportActionsUtils'; +import {setOnReportActionListLoadedInTests} from '@pages/home/report/ReportActionsList/testUtils'; import FontUtils from '@styles/utils/FontUtils'; import App from '@src/App'; import CONST from '@src/CONST'; @@ -40,6 +41,7 @@ jest.mock('@react-navigation/native'); jest.mock('../../src/libs/Notification/LocalNotification'); jest.mock('../../src/components/Icon/Expensicons'); jest.mock('../../src/components/ConfirmedRoute.tsx'); +jest.mock('../../src/pages/home/report/ReportActionsList/testUtils'); TestHelper.setupApp(); TestHelper.setupGlobalFetchMock(); @@ -286,6 +288,8 @@ describe('Unread Indicators', () => { expect(areYouOnChatListScreen()).toBe(false); })); it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => { + let reportActionListLoadedPromise: Promise; + signInAndGetAppWithUnreadChat() .then(() => { // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant @@ -364,19 +368,15 @@ describe('Unread Indicators', () => { expect((secondReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); expect(screen.getByText('B User')).toBeOnTheScreen(); - let reportActionsListInitialLoadListener: EventSubscription; - const chatListInitialLoadPromise = new Promise((resolve) => { - reportActionsListInitialLoadListener = DeviceEventEmitter.addListener(CONST.EVENTS.REPORT_ACTIONS_LIST_INITIALLY_LOADED, resolve); + reportActionListLoadedPromise = new Promise((resolve) => { + setOnReportActionListLoadedInTests(resolve); }); // Tap the new report option and navigate back to the sidebar again via the back button - const navigationPromise = navigateToSidebarOption(0); - - return Promise.all([navigationPromise, chatListInitialLoadPromise]).then(() => { - reportActionsListInitialLoadListener.remove(); - }); + return navigateToSidebarOption(0); }) .then(waitForBatchedUpdates) + .then(() => reportActionListLoadedPromise) .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread @@ -449,13 +449,7 @@ describe('Unread Indicators', () => { it('Keep showing the new line indicator when a new message is created by the current user', () => signInAndGetAppWithUnreadChat() - .then(() => { - // Verify we are on the LHN and that the chat shows as unread in the LHN - expect(areYouOnChatListScreen()).toBe(true); - - // Navigate to the report and verify the indicator is present - return navigateToSidebarOption(0); - }) + .then(() => navigateToSidebarOption(0)) .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); From fd5ab55cdbbc2d3089f8e9e2e96227e533b77de8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 22:40:03 +0200 Subject: [PATCH 038/216] Update CONST.ts --- src/CONST.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CONST.ts b/src/CONST.ts index e123ad684ec3..eca3135bcc18 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5189,7 +5189,6 @@ const CONST = { }, EVENTS: { SCROLLING: 'scrolling', - REPORT_ACTIONS_LIST_INITIALLY_LOADED: 'reportActionsListInitiallyLoaded', }, SELECTION_LIST_WITH_MODAL_TEST_ID: 'selectionListWithModalMenuItem', From c70684380e9f1d543739e2b44ae2ace315983e92 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 22:40:15 +0200 Subject: [PATCH 039/216] fix: better mock BaseInvertedFlatList --- jest/setup.ts | 22 +++++++++++++------ .../__mocks__/useInitialListEventMocks.ts | 17 ++++++++++++++ .../BaseInvertedFlatList/index.tsx | 3 +++ .../useInitialListEventMocks.ts | 13 +++++++++++ tests/ui/UnreadIndicatorsTest.tsx | 12 +++++++--- 5 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts create mode 100644 src/components/InvertedFlatList/BaseInvertedFlatList/useInitialListEventMocks.ts diff --git a/jest/setup.ts b/jest/setup.ts index 8148b2b159bc..fc6a5fb9df20 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -6,6 +6,7 @@ import type * as RNKeyboardController from 'react-native-keyboard-controller'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import type Animated from 'react-native-reanimated'; import 'setimmediate'; +import type {RenderInfo} from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; @@ -131,28 +132,35 @@ jest.mock( '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue', () => class SyncRenderTaskQueue { + private renderInfo: RenderInfo | undefined = undefined; + private handler: ((info: unknown) => void) | undefined = undefined; - private onEndReached: (() => void) | undefined = undefined; + add(info: RenderInfo, startRendering = true) { + this.renderInfo = info; - add(info: unknown) { - this.handler?.(info); + if (startRendering) { + this.handler?.(info); + } } - start() {} + start() { + this.handler?.(this.renderInfo); + this.renderInfo = undefined; + } setHandler(handler: (info: unknown) => void) { this.handler = handler; } - setOnEndReached(onEndReached: (() => void) | undefined) { - this.onEndReached = onEndReached; - } + setOnEndReached() {} cancel() {} }, ); +jest.mock('@components/InvertedFlatList/BaseInvertedFlatList/useInitialListEventMocks'); + jest.mock('@libs/prepareRequestPayload/index.native.ts', () => ({ // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts b/src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts new file mode 100644 index 000000000000..3b7da69f6bc7 --- /dev/null +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts @@ -0,0 +1,17 @@ +import {useEffect, useRef} from 'react'; +import type {UseInitialListEventMocks} from '@components/InvertedFlatList/BaseInvertedFlatList/useInitialListEventMocks'; + +const useInitialListEventMocks: UseInitialListEventMocks = ({handleStartReached, handleContentSizeChange}) => { + const didTriggerEvents = useRef(false); + + useEffect(() => { + if (didTriggerEvents.current) { + return; + } + handleStartReached({distanceFromStart: 0}); + handleContentSizeChange(0, 0); + didTriggerEvents.current = true; + }, [handleStartReached, handleContentSizeChange]); +}; + +export default useInitialListEventMocks; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index df45f731bea4..69d5b066d977 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -6,6 +6,7 @@ import usePrevious from '@hooks/usePrevious'; import CONST from '@src/CONST'; import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; +import useInitialListEventMocks from './useInitialListEventMocks'; const INITIAL_SCROLL_DELAY = 200; @@ -202,6 +203,8 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa ) as RNFlatList; }); + useInitialListEventMocks({handleStartReached, handleContentSizeChange}); + return ( void; + handleContentSizeChange: (contentWidth: number, contentHeight: number) => void; +}; +type UseInitialListEventMocks = (props: UseInitialListEventMocksProps) => void; + +const NOOP = () => {}; +const useInitialListEventMocks: UseInitialListEventMocks = NOOP; + +export default useInitialListEventMocks; +export type {UseInitialListEventMocks}; diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 8663036ba9d8..fb4832b21536 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -290,7 +290,7 @@ describe('Unread Indicators', () => { it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => { let reportActionListLoadedPromise: Promise; - signInAndGetAppWithUnreadChat() + return signInAndGetAppWithUnreadChat() .then(() => { // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant // We set the created date 5 seconds in the past to ensure that time has passed when we open the report @@ -375,8 +375,8 @@ describe('Unread Indicators', () => { // Tap the new report option and navigate back to the sidebar again via the back button return navigateToSidebarOption(0); }) - .then(waitForBatchedUpdates) .then(() => reportActionListLoadedPromise) + .then(waitForBatchedUpdates) .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread @@ -449,7 +449,13 @@ describe('Unread Indicators', () => { it('Keep showing the new line indicator when a new message is created by the current user', () => signInAndGetAppWithUnreadChat() - .then(() => navigateToSidebarOption(0)) + .then(() => { + // Verify we are on the LHN and that the chat shows as unread in the LHN + expect(areYouOnChatListScreen()).toBe(true); + + // Navigate to the report and verify the indicator is present + return navigateToSidebarOption(0); + }) .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); const newMessageLineIndicatorHintText = translateLocal('accessibilityHints.newMessageLineIndicator'); From efc32e1cf073603367a495668c24e33336ae8b1c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 22:46:51 +0200 Subject: [PATCH 040/216] revert testing changes --- jest/setup.ts | 17 +++-------------- .../__mocks__/useInitialListEventMocks.ts | 17 ----------------- .../BaseInvertedFlatList/index.tsx | 3 --- .../useInitialListEventMocks.ts | 13 ------------- .../index.tsx => ReportActionsList.tsx} | 4 +--- .../ReportActionsList/__mocks__/testUtils.ts | 11 ----------- .../home/report/ReportActionsList/testUtils.ts | 9 --------- tests/ui/UnreadIndicatorsTest.tsx | 16 +++------------- 8 files changed, 7 insertions(+), 83 deletions(-) delete mode 100644 src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts delete mode 100644 src/components/InvertedFlatList/BaseInvertedFlatList/useInitialListEventMocks.ts rename src/pages/home/report/{ReportActionsList/index.tsx => ReportActionsList.tsx} (99%) delete mode 100644 src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts delete mode 100644 src/pages/home/report/ReportActionsList/testUtils.ts diff --git a/jest/setup.ts b/jest/setup.ts index fc6a5fb9df20..de4623d204b3 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -132,22 +132,13 @@ jest.mock( '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue', () => class SyncRenderTaskQueue { - private renderInfo: RenderInfo | undefined = undefined; - private handler: ((info: unknown) => void) | undefined = undefined; - add(info: RenderInfo, startRendering = true) { - this.renderInfo = info; - - if (startRendering) { - this.handler?.(info); - } + add(info: RenderInfo) { + this.handler?.(info); } - start() { - this.handler?.(this.renderInfo); - this.renderInfo = undefined; - } + start() {} setHandler(handler: (info: unknown) => void) { this.handler = handler; @@ -159,8 +150,6 @@ jest.mock( }, ); -jest.mock('@components/InvertedFlatList/BaseInvertedFlatList/useInitialListEventMocks'); - jest.mock('@libs/prepareRequestPayload/index.native.ts', () => ({ // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts b/src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts deleted file mode 100644 index 3b7da69f6bc7..000000000000 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/__mocks__/useInitialListEventMocks.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {useEffect, useRef} from 'react'; -import type {UseInitialListEventMocks} from '@components/InvertedFlatList/BaseInvertedFlatList/useInitialListEventMocks'; - -const useInitialListEventMocks: UseInitialListEventMocks = ({handleStartReached, handleContentSizeChange}) => { - const didTriggerEvents = useRef(false); - - useEffect(() => { - if (didTriggerEvents.current) { - return; - } - handleStartReached({distanceFromStart: 0}); - handleContentSizeChange(0, 0); - didTriggerEvents.current = true; - }, [handleStartReached, handleContentSizeChange]); -}; - -export default useInitialListEventMocks; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 69d5b066d977..df45f731bea4 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -6,7 +6,6 @@ import usePrevious from '@hooks/usePrevious'; import CONST from '@src/CONST'; import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; -import useInitialListEventMocks from './useInitialListEventMocks'; const INITIAL_SCROLL_DELAY = 200; @@ -203,8 +202,6 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa ) as RNFlatList; }); - useInitialListEventMocks({handleStartReached, handleContentSizeChange}); - return ( void; - handleContentSizeChange: (contentWidth: number, contentHeight: number) => void; -}; -type UseInitialListEventMocks = (props: UseInitialListEventMocksProps) => void; - -const NOOP = () => {}; -const useInitialListEventMocks: UseInitialListEventMocks = NOOP; - -export default useInitialListEventMocks; -export type {UseInitialListEventMocks}; diff --git a/src/pages/home/report/ReportActionsList/index.tsx b/src/pages/home/report/ReportActionsList.tsx similarity index 99% rename from src/pages/home/report/ReportActionsList/index.tsx rename to src/pages/home/report/ReportActionsList.tsx index 98b46c4f70f9..6437ec5420f8 100644 --- a/src/pages/home/report/ReportActionsList/index.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -64,7 +64,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import {onReportActionListLoadedInTests} from './testUtils'; type ReportActionsListProps = { /** The report currently being looked at */ @@ -374,8 +373,7 @@ function ReportActionsList({ const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); const handleListInitiallyLoaded = useCallback(() => { setIsListInitiallyLoaded(true); - onReportActionListLoadedInTests(report.reportID); - }, [report.reportID]); + }, []); const isReportUnread = useMemo( () => isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction)), diff --git a/src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts b/src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts deleted file mode 100644 index d1dfb85afdbe..000000000000 --- a/src/pages/home/report/ReportActionsList/__mocks__/testUtils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {OnReportActionListLoadedInTests} from '@pages/home/report/ReportActionsList/testUtils'; - -const NOOP = () => {}; - -// eslint-disable-next-line import/no-mutable-exports -let onReportActionListLoadedInTests: OnReportActionListLoadedInTests = NOOP; -const setOnReportActionListLoadedInTests = (callback: OnReportActionListLoadedInTests) => { - onReportActionListLoadedInTests = callback; -}; - -export {onReportActionListLoadedInTests, setOnReportActionListLoadedInTests}; diff --git a/src/pages/home/report/ReportActionsList/testUtils.ts b/src/pages/home/report/ReportActionsList/testUtils.ts deleted file mode 100644 index a1db44dd1560..000000000000 --- a/src/pages/home/report/ReportActionsList/testUtils.ts +++ /dev/null @@ -1,9 +0,0 @@ -type OnReportActionListLoadedInTests = (reportID: string) => void; - -const NOOP = () => {}; - -const onReportActionListLoadedInTests: OnReportActionListLoadedInTests = NOOP; -const setOnReportActionListLoadedInTests: (callback: OnReportActionListLoadedInTests) => void = NOOP; - -export {onReportActionListLoadedInTests, setOnReportActionListLoadedInTests}; -export type {OnReportActionListLoadedInTests}; diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index fb4832b21536..3c4755c0a8b4 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -18,7 +18,6 @@ import {translateLocal} from '@libs/Localize'; import LocalNotification from '@libs/Notification/LocalNotification'; import {rand64} from '@libs/NumberUtils'; import {getReportActionText} from '@libs/ReportActionsUtils'; -import {setOnReportActionListLoadedInTests} from '@pages/home/report/ReportActionsList/testUtils'; import FontUtils from '@styles/utils/FontUtils'; import App from '@src/App'; import CONST from '@src/CONST'; @@ -41,7 +40,6 @@ jest.mock('@react-navigation/native'); jest.mock('../../src/libs/Notification/LocalNotification'); jest.mock('../../src/components/Icon/Expensicons'); jest.mock('../../src/components/ConfirmedRoute.tsx'); -jest.mock('../../src/pages/home/report/ReportActionsList/testUtils'); TestHelper.setupApp(); TestHelper.setupGlobalFetchMock(); @@ -287,10 +285,8 @@ describe('Unread Indicators', () => { expect(unreadIndicator).toHaveLength(0); expect(areYouOnChatListScreen()).toBe(false); })); - it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => { - let reportActionListLoadedPromise: Promise; - - return signInAndGetAppWithUnreadChat() + it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => + signInAndGetAppWithUnreadChat() .then(() => { // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant // We set the created date 5 seconds in the past to ensure that time has passed when we open the report @@ -368,14 +364,9 @@ describe('Unread Indicators', () => { expect((secondReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); expect(screen.getByText('B User')).toBeOnTheScreen(); - reportActionListLoadedPromise = new Promise((resolve) => { - setOnReportActionListLoadedInTests(resolve); - }); - // Tap the new report option and navigate back to the sidebar again via the back button return navigateToSidebarOption(0); }) - .then(() => reportActionListLoadedPromise) .then(waitForBatchedUpdates) .then(async () => { await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); @@ -388,8 +379,7 @@ describe('Unread Indicators', () => { expect(screen.getAllByText('C User').at(0)).toBeOnTheScreen(); expect((displayNameTexts.at(1)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); expect(screen.getByText('B User', {includeHiddenElements: true})).toBeOnTheScreen(); - }); - }); + })); xit('Manually marking a chat message as unread shows the new line indicator and updates the LHN', () => signInAndGetAppWithUnreadChat() From bee7bf7b4a1fd9118807cf9866be5944b2864b01 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 22:50:48 +0200 Subject: [PATCH 041/216] fix: imports --- src/pages/home/report/ReportActionsList.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 6437ec5420f8..bb5720c7e4f7 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -50,12 +50,6 @@ import { } from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; -import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter'; -import getInitialNumToRender from '@pages/home/report/getInitialNumReportActionsToRender'; -import ListBoundaryLoader from '@pages/home/report/ListBoundaryLoader'; -import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer'; -import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction'; -import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking'; import variables from '@styles/variables'; import {getCurrentUserAccountID, openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import {PersonalDetailsContext} from '@src/components/OnyxProvider'; @@ -64,6 +58,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; +import shouldDisplayNewMarkerOnReportAction from './shouldDisplayNewMarkerOnReportAction'; +import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; +import ListBoundaryLoader from './ListBoundaryLoader'; +import getInitialNumToRender from './getInitialNumReportActionsToRender'; +import FloatingMessageCounter from './FloatingMessageCounter'; type ReportActionsListProps = { /** The report currently being looked at */ From aa3d0dfce46561af6146a84bbecadd2cc69e1ceb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 23 May 2025 22:56:58 +0200 Subject: [PATCH 042/216] fix: readActionSkipped fix --- src/pages/home/report/ReportActionsList.tsx | 39 +++++++++++---------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index bb5720c7e4f7..98439a3268ab 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -58,12 +58,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; -import shouldDisplayNewMarkerOnReportAction from './shouldDisplayNewMarkerOnReportAction'; -import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; -import ListBoundaryLoader from './ListBoundaryLoader'; -import getInitialNumToRender from './getInitialNumReportActionsToRender'; import FloatingMessageCounter from './FloatingMessageCounter'; +import getInitialNumToRender from './getInitialNumReportActionsToRender'; +import ListBoundaryLoader from './ListBoundaryLoader'; +import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; +import shouldDisplayNewMarkerOnReportAction from './shouldDisplayNewMarkerOnReportAction'; +import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; type ReportActionsListProps = { /** The report currently being looked at */ @@ -401,20 +401,22 @@ function ReportActionsList({ return; } - if (isListInitiallyLoaded && isReportUnread) { - // On desktop, when the notification center is displayed, isVisible will return false. - // 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; - const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; - - if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { - readNewestAction(report.reportID); - if (isFromNotification) { - Navigation.setParams({referrer: undefined}); - } - return; + if (!isListInitiallyLoaded || !isReportUnread) { + return; + } + + // On desktop, when the notification center is displayed, isVisible will return false. + // 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; + const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; + + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { + readNewestAction(report.reportID); + if (isFromNotification) { + Navigation.setParams({referrer: undefined}); } + return; } readActionSkipped.current = true; @@ -767,6 +769,5 @@ function ReportActionsList({ ReportActionsList.displayName = 'ReportActionsList'; export default memo(ReportActionsList); -export {onReportActionListLoadedInTests}; export type {ReportActionsListProps}; From 82db3c0505ee5c33084d8487b70b15b9ce93f627 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 26 May 2025 09:36:57 +0200 Subject: [PATCH 043/216] fix: wait for ReadNewestAction in test --- tests/ui/UnreadIndicatorsTest.tsx | 187 ++++++++++++++++-------------- 1 file changed, 97 insertions(+), 90 deletions(-) diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 3c4755c0a8b4..f561dc158221 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -285,101 +285,108 @@ describe('Unread Indicators', () => { expect(unreadIndicator).toHaveLength(0); expect(areYouOnChatListScreen()).toBe(false); })); - it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => - signInAndGetAppWithUnreadChat() - .then(() => { - // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant - // We set the created date 5 seconds in the past to ensure that time has passed when we open the report - const NEW_REPORT_ID = '2'; - const NEW_REPORT_CREATED_DATE = subSeconds(new Date(), 5); - const NEW_REPORT_FIST_MESSAGE_CREATED_DATE = addSeconds(NEW_REPORT_CREATED_DATE, 1); - const createdReportActionIDLocal = rand64(); - const commentReportActionID = rand64(); - PusherHelper.emitOnyxUpdate([ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${NEW_REPORT_ID}`, - value: { - reportID: NEW_REPORT_ID, - reportName: CONST.REPORT.DEFAULT_REPORT_NAME, - lastReadTime: '', - lastVisibleActionCreated: DateUtils.getDBTime(toZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()), - lastMessageText: 'Comment 1', - lastActorAccountID: USER_C_ACCOUNT_ID, - participants: { - [USER_C_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + it( + 'Shows a browser notification and bold text when a new message arrives for a chat that is read', + () => + signInAndGetAppWithUnreadChat() + .then(() => { + // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant + // We set the created date 5 seconds in the past to ensure that time has passed when we open the report + const NEW_REPORT_ID = '2'; + const NEW_REPORT_CREATED_DATE = subSeconds(new Date(), 5); + const NEW_REPORT_FIST_MESSAGE_CREATED_DATE = addSeconds(NEW_REPORT_CREATED_DATE, 1); + const createdReportActionIDLocal = rand64(); + const commentReportActionID = rand64(); + PusherHelper.emitOnyxUpdate([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${NEW_REPORT_ID}`, + value: { + reportID: NEW_REPORT_ID, + reportName: CONST.REPORT.DEFAULT_REPORT_NAME, + lastReadTime: '', + lastVisibleActionCreated: DateUtils.getDBTime(toZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()), + lastMessageText: 'Comment 1', + lastActorAccountID: USER_C_ACCOUNT_ID, + participants: { + [USER_C_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + type: CONST.REPORT.TYPE.CHAT, }, - type: CONST.REPORT.TYPE.CHAT, }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${NEW_REPORT_ID}`, - value: { - [createdReportActionIDLocal]: { - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - automatic: false, - created: format(NEW_REPORT_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), - reportActionID: createdReportActionIDLocal, - }, - [commentReportActionID]: { - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: USER_C_ACCOUNT_ID, - person: [{type: 'TEXT', style: 'strong', text: 'User C'}], - created: format(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), - message: [{type: 'COMMENT', html: 'Comment 1', text: 'Comment 1'}], - reportActionID: commentReportActionID, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${NEW_REPORT_ID}`, + value: { + [createdReportActionIDLocal]: { + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + automatic: false, + created: format(NEW_REPORT_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), + reportActionID: createdReportActionIDLocal, + }, + [commentReportActionID]: { + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: USER_C_ACCOUNT_ID, + person: [{type: 'TEXT', style: 'strong', text: 'User C'}], + created: format(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), + message: [{type: 'COMMENT', html: 'Comment 1', text: 'Comment 1'}], + reportActionID: commentReportActionID, + }, }, + shouldNotify: true, }, - shouldNotify: true, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: { - [USER_C_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'), + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [USER_C_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'), + }, }, - }, - ]); - return waitForBatchedUpdates(); - }) - .then(() => { - // Verify notification was created - expect(LocalNotification.showCommentNotification).toBeCalled(); - }) - .then(() => { - // // Verify the new report option appears in the LHN - const optionRows = screen.queryAllByAccessibilityHint(TestHelper.getNavigateToChatHintRegex()); - expect(optionRows).toHaveLength(2); - // Verify the text for both chats are bold indicating that nothing has not yet been read - const displayNameHintTexts = translateLocal('accessibilityHints.chatUserDisplayNames'); - const displayNameTexts = screen.queryAllByLabelText(displayNameHintTexts); - expect(displayNameTexts).toHaveLength(2); - const firstReportOption = displayNameTexts.at(0); - expect((firstReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); - expect(screen.getByText('C User')).toBeOnTheScreen(); - - const secondReportOption = displayNameTexts.at(1); - expect((secondReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); - expect(screen.getByText('B User')).toBeOnTheScreen(); - - // Tap the new report option and navigate back to the sidebar again via the back button - return navigateToSidebarOption(0); - }) - .then(waitForBatchedUpdates) - .then(async () => { - await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); - // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread - const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); - const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); - - expect(displayNameTexts).toHaveLength(2); - expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.normal); - expect(screen.getAllByText('C User').at(0)).toBeOnTheScreen(); - expect((displayNameTexts.at(1)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); - expect(screen.getByText('B User', {includeHiddenElements: true})).toBeOnTheScreen(); - })); + ]); + return waitForBatchedUpdates(); + }) + .then(() => { + // Verify notification was created + expect(LocalNotification.showCommentNotification).toBeCalled(); + }) + .then(() => { + // // Verify the new report option appears in the LHN + const optionRows = screen.queryAllByAccessibilityHint(TestHelper.getNavigateToChatHintRegex()); + expect(optionRows).toHaveLength(2); + // Verify the text for both chats are bold indicating that nothing has not yet been read + const displayNameHintTexts = translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameTexts = screen.queryAllByLabelText(displayNameHintTexts); + expect(displayNameTexts).toHaveLength(2); + const firstReportOption = displayNameTexts.at(0); + expect((firstReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); + expect(screen.getByText('C User')).toBeOnTheScreen(); + + const secondReportOption = displayNameTexts.at(1); + expect((secondReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); + expect(screen.getByText('B User')).toBeOnTheScreen(); + + // Tap the new report option and navigate back to the sidebar again via the back button + return navigateToSidebarOption(0); + }) + // We need to wait for the "ReadNewestAction" API call to be triggered. After that, + // the previous report will be marked as read and the chat display name will be updated. + .then(() => + waitFor(() => async () => { + await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); + // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread + const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); + + expect(displayNameTexts).toHaveLength(2); + expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.normal); + expect(screen.getAllByText('C User').at(0)).toBeOnTheScreen(); + expect((displayNameTexts.at(1)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); + expect(screen.getByText('B User', {includeHiddenElements: true})).toBeOnTheScreen(); + }), + ), + 100000000000, + ); xit('Manually marking a chat message as unread shows the new line indicator and updates the LHN', () => signInAndGetAppWithUnreadChat() From 236e7a06d7065dbfb1d3acf68b82dcc0ecf8f3df Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 26 May 2025 09:56:53 +0200 Subject: [PATCH 044/216] fix: remove timeout --- tests/ui/UnreadIndicatorsTest.tsx | 186 +++++++++++++++--------------- 1 file changed, 91 insertions(+), 95 deletions(-) diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index f561dc158221..0b97fef1fbbd 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -285,108 +285,104 @@ describe('Unread Indicators', () => { expect(unreadIndicator).toHaveLength(0); expect(areYouOnChatListScreen()).toBe(false); })); - it( - 'Shows a browser notification and bold text when a new message arrives for a chat that is read', - () => - signInAndGetAppWithUnreadChat() - .then(() => { - // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant - // We set the created date 5 seconds in the past to ensure that time has passed when we open the report - const NEW_REPORT_ID = '2'; - const NEW_REPORT_CREATED_DATE = subSeconds(new Date(), 5); - const NEW_REPORT_FIST_MESSAGE_CREATED_DATE = addSeconds(NEW_REPORT_CREATED_DATE, 1); - const createdReportActionIDLocal = rand64(); - const commentReportActionID = rand64(); - PusherHelper.emitOnyxUpdate([ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${NEW_REPORT_ID}`, - value: { - reportID: NEW_REPORT_ID, - reportName: CONST.REPORT.DEFAULT_REPORT_NAME, - lastReadTime: '', - lastVisibleActionCreated: DateUtils.getDBTime(toZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()), - lastMessageText: 'Comment 1', - lastActorAccountID: USER_C_ACCOUNT_ID, - participants: { - [USER_C_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - type: CONST.REPORT.TYPE.CHAT, + it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => + signInAndGetAppWithUnreadChat() + .then(() => { + // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant + // We set the created date 5 seconds in the past to ensure that time has passed when we open the report + const NEW_REPORT_ID = '2'; + const NEW_REPORT_CREATED_DATE = subSeconds(new Date(), 5); + const NEW_REPORT_FIST_MESSAGE_CREATED_DATE = addSeconds(NEW_REPORT_CREATED_DATE, 1); + const createdReportActionIDLocal = rand64(); + const commentReportActionID = rand64(); + PusherHelper.emitOnyxUpdate([ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${NEW_REPORT_ID}`, + value: { + reportID: NEW_REPORT_ID, + reportName: CONST.REPORT.DEFAULT_REPORT_NAME, + lastReadTime: '', + lastVisibleActionCreated: DateUtils.getDBTime(toZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()), + lastMessageText: 'Comment 1', + lastActorAccountID: USER_C_ACCOUNT_ID, + participants: { + [USER_C_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [USER_A_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, + type: CONST.REPORT.TYPE.CHAT, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${NEW_REPORT_ID}`, - value: { - [createdReportActionIDLocal]: { - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - automatic: false, - created: format(NEW_REPORT_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), - reportActionID: createdReportActionIDLocal, - }, - [commentReportActionID]: { - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: USER_C_ACCOUNT_ID, - person: [{type: 'TEXT', style: 'strong', text: 'User C'}], - created: format(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), - message: [{type: 'COMMENT', html: 'Comment 1', text: 'Comment 1'}], - reportActionID: commentReportActionID, - }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${NEW_REPORT_ID}`, + value: { + [createdReportActionIDLocal]: { + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + automatic: false, + created: format(NEW_REPORT_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), + reportActionID: createdReportActionIDLocal, }, - shouldNotify: true, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: { - [USER_C_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'), + [commentReportActionID]: { + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: USER_C_ACCOUNT_ID, + person: [{type: 'TEXT', style: 'strong', text: 'User C'}], + created: format(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, CONST.DATE.FNS_DB_FORMAT_STRING), + message: [{type: 'COMMENT', html: 'Comment 1', text: 'Comment 1'}], + reportActionID: commentReportActionID, }, }, - ]); - return waitForBatchedUpdates(); - }) - .then(() => { - // Verify notification was created - expect(LocalNotification.showCommentNotification).toBeCalled(); - }) - .then(() => { - // // Verify the new report option appears in the LHN - const optionRows = screen.queryAllByAccessibilityHint(TestHelper.getNavigateToChatHintRegex()); - expect(optionRows).toHaveLength(2); - // Verify the text for both chats are bold indicating that nothing has not yet been read - const displayNameHintTexts = translateLocal('accessibilityHints.chatUserDisplayNames'); - const displayNameTexts = screen.queryAllByLabelText(displayNameHintTexts); - expect(displayNameTexts).toHaveLength(2); - const firstReportOption = displayNameTexts.at(0); - expect((firstReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); - expect(screen.getByText('C User')).toBeOnTheScreen(); + shouldNotify: true, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [USER_C_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'), + }, + }, + ]); + return waitForBatchedUpdates(); + }) + .then(() => { + // Verify notification was created + expect(LocalNotification.showCommentNotification).toBeCalled(); + }) + .then(() => { + // // Verify the new report option appears in the LHN + const optionRows = screen.queryAllByAccessibilityHint(TestHelper.getNavigateToChatHintRegex()); + expect(optionRows).toHaveLength(2); + // Verify the text for both chats are bold indicating that nothing has not yet been read + const displayNameHintTexts = translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameTexts = screen.queryAllByLabelText(displayNameHintTexts); + expect(displayNameTexts).toHaveLength(2); + const firstReportOption = displayNameTexts.at(0); + expect((firstReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); + expect(screen.getByText('C User')).toBeOnTheScreen(); + + const secondReportOption = displayNameTexts.at(1); + expect((secondReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); + expect(screen.getByText('B User')).toBeOnTheScreen(); - const secondReportOption = displayNameTexts.at(1); - expect((secondReportOption?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); - expect(screen.getByText('B User')).toBeOnTheScreen(); + // Tap the new report option and navigate back to the sidebar again via the back button + return navigateToSidebarOption(0); + }) + // We need to wait for the "ReadNewestAction" API call to be triggered. After that, + // the previous report will be marked as read and the chat display name will be updated. + .then(() => + waitFor(() => async () => { + await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); + // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread + const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); - // Tap the new report option and navigate back to the sidebar again via the back button - return navigateToSidebarOption(0); - }) - // We need to wait for the "ReadNewestAction" API call to be triggered. After that, - // the previous report will be marked as read and the chat display name will be updated. - .then(() => - waitFor(() => async () => { - await act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); - // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread - const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); - const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); - - expect(displayNameTexts).toHaveLength(2); - expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.normal); - expect(screen.getAllByText('C User').at(0)).toBeOnTheScreen(); - expect((displayNameTexts.at(1)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); - expect(screen.getByText('B User', {includeHiddenElements: true})).toBeOnTheScreen(); - }), - ), - 100000000000, - ); + expect(displayNameTexts).toHaveLength(2); + expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.normal); + expect(screen.getAllByText('C User').at(0)).toBeOnTheScreen(); + expect((displayNameTexts.at(1)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); + expect(screen.getByText('B User', {includeHiddenElements: true})).toBeOnTheScreen(); + }), + )); xit('Manually marking a chat message as unread shows the new line indicator and updates the LHN', () => signInAndGetAppWithUnreadChat() From cc5a5b1e65dfc3786b5cf4a8f2df88ef3dd47546 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 29 May 2025 16:26:09 +0200 Subject: [PATCH 045/216] fix: eslint --- src/pages/home/report/ReportActionCompose/SuggestionMention.tsx | 2 +- src/pages/home/report/ReportActionsList.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx index 92bed91f2930..c5bfc0807e58 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx @@ -92,7 +92,7 @@ function SuggestionMention( // eslint-disable-next-line react-compiler/react-compiler suggestionValuesRef.current = suggestionValues; - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isMentionSuggestionsMenuVisible = !!suggestionValues.suggestedMentions.length && suggestionValues.shouldShowSuggestionMenu; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 13ffb75588c9..bd30508e8561 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -425,7 +425,7 @@ function ReportActionsList({ }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, isListInitiallyLoaded, hasNewerActions]); useEffect(() => { - if (linkedReportActionID || unreadMarkerReportActionID) { + if (!!linkedReportActionID || !!unreadMarkerReportActionID) { return; } InteractionManager.runAfterInteractions(() => { From 59e24752521082e0f3d49aca48396e23ee5ab484 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 3 Jun 2025 15:50:43 +0200 Subject: [PATCH 046/216] fix: only call `onInitiallyLoaded` once and improve naming of variable --- .../InvertedFlatList/BaseInvertedFlatList/index.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index df45f731bea4..7fce92d3b5eb 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -85,18 +85,18 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); const didScroll = useRef(false); - const [hasInitialContentBeenRendered, setHasInitialContentBeenRendered] = useState(false); + const [didInitialContentRender, setDidInitialContentRender] = useState(false); const handleContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { onContentSizeChange?.(contentWidth, contentHeight); - setHasInitialContentBeenRendered(true); + setDidInitialContentRender(true); }, [onContentSizeChange], ); useEffect(() => { - if (didScroll.current || !isMessageOnFirstPage.current || !hasInitialContentBeenRendered) { + if (didScroll.current || !isMessageOnFirstPage.current || !didInitialContentRender) { return; } @@ -108,7 +108,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa didScroll.current = true; renderQueue.start(); }, INITIAL_SCROLL_DELAY); - }, [currentDataIndex, data.length, displayedData.length, hasInitialContentBeenRendered, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue]); + }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue]); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); @@ -121,10 +121,9 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa if (isInitialData) { setIsInitialData(false); + onInitiallyLoaded?.(); } - onInitiallyLoaded?.(); - const firstDisplayedItem = displayedData.at(0); setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); }); From fbb0355e28dd8c8f431d01e136e383d292527f40 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 3 Jun 2025 15:51:08 +0200 Subject: [PATCH 047/216] fix: add a max waiting time to GetNewerMessages/GetOlderMessages trigger --- src/hooks/useLoadReportActions.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 8809dda0c2c0..2b88e08ef808 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -7,6 +7,13 @@ import type {Report, ReportAction, Response} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import useNetwork from './useNetwork'; +const GET_REPORT_ACTIONS_MAX_WAITING_TIME = 3000; +function createMaxWaitingTimePromise(maxWaitingTime: number) { + return new Promise((resolve) => { + setTimeout(resolve, maxWaitingTime); + }); +} + type UseLoadReportActionsArguments = { /** The id of the current report */ reportID: string; @@ -35,14 +42,6 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran const isLoadingNewerChats = useRef(false); const isLoadingOlderChats = useRef(false); - const resetIsLoadingOlderChats = () => { - isLoadingOlderChats.current = false; - }; - - const resetIsLoadingNewerChats = () => { - isLoadingNewerChats.current = false; - }; - const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); @@ -87,7 +86,9 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran getOlderActionsPromises.push(getOlderActions(reportID, oldestReportAction.reportActionID)); } - Promise.all(getOlderActionsPromises).then(resetIsLoadingOlderChats); + Promise.race([createMaxWaitingTimePromise(GET_REPORT_ACTIONS_MAX_WAITING_TIME), Promise.all(getOlderActionsPromises)]).then(() => { + isLoadingOlderChats.current = false; + }); }, [isOffline, oldestReportAction, reportID, reportActionIDMap, transactionThreadReport, hasOlderActions], ); @@ -125,7 +126,9 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran getNewerActionsPromises.push(getNewerActions(reportID, newestReportAction.reportActionID)); } - Promise.all(getNewerActionsPromises).then(resetIsLoadingNewerChats); + Promise.race([createMaxWaitingTimePromise(GET_REPORT_ACTIONS_MAX_WAITING_TIME), Promise.all(getNewerActionsPromises)]).then(() => { + isLoadingNewerChats.current = false; + }); }, [isFocused, newestReportAction, hasNewerActions, isOffline, transactionThreadReport, reportActionIDMap, reportID], ); From a42da60355f47e5bf7fc6e4238bca40604fc45ce Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 4 Jun 2025 18:20:25 +0200 Subject: [PATCH 048/216] fix: firefox chat scroll content cut off --- src/styles/utils/chatContentScrollViewPlatformStyles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/utils/chatContentScrollViewPlatformStyles/index.ts b/src/styles/utils/chatContentScrollViewPlatformStyles/index.ts index cef1d5e70e6e..36d5c73ae533 100644 --- a/src/styles/utils/chatContentScrollViewPlatformStyles/index.ts +++ b/src/styles/utils/chatContentScrollViewPlatformStyles/index.ts @@ -1,7 +1,7 @@ import type ChatContentScrollViewPlatformStyles from './types'; const chatContentScrollViewPlatformStyles: ChatContentScrollViewPlatformStyles = { - overflow: 'hidden', + overflowY: 'clip', }; export default chatContentScrollViewPlatformStyles; From 917cf42378e52b5ec59a9ffe0141191a84b62145 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Jun 2025 16:00:57 +0200 Subject: [PATCH 049/216] fix: add waitFor to Test --- tests/utils/ReportTestUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts index dd1886039926..602b8e00cb21 100644 --- a/tests/utils/ReportTestUtils.ts +++ b/tests/utils/ReportTestUtils.ts @@ -1,5 +1,5 @@ import * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, screen, within} from '@testing-library/react-native'; +import {act, fireEvent, screen, waitFor, within} from '@testing-library/react-native'; import {translateLocal} from '@libs/Localize'; import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; @@ -122,7 +122,7 @@ function triggerListLayout(reportID?: string) { async function navigateToSidebarOption(reportID: string): Promise { const optionRow = screen.getByTestId(reportID); fireEvent(optionRow, 'press'); - await act(() => { + await waitFor(() => { (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); }); // ReportScreen relies on the onLayout event to receive updates from onyx. From 056c6f6ac4bd0dc9367627ebc8464de5c51c922f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Jun 2025 16:13:50 +0200 Subject: [PATCH 050/216] remove unused import --- tests/utils/ReportTestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts index 602b8e00cb21..dff009678580 100644 --- a/tests/utils/ReportTestUtils.ts +++ b/tests/utils/ReportTestUtils.ts @@ -1,5 +1,5 @@ import * as NativeNavigation from '@react-navigation/native'; -import {act, fireEvent, screen, waitFor, within} from '@testing-library/react-native'; +import {fireEvent, screen, waitFor, within} from '@testing-library/react-native'; import {translateLocal} from '@libs/Localize'; import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; From 73b863352a58cd1205d90cfac27a9e14219688c3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Jun 2025 16:14:41 +0200 Subject: [PATCH 051/216] fix: report screen not loading --- src/pages/home/ReportScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 72bae3c2ef4d..e2637b073344 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -825,7 +825,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { testID="report-actions-view-wrapper" > {(!report || !isReportReady || shouldWaitForTransactions) && } - {!!report && !isReportReady && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions && ( + {!!report && isReportReady && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions && ( Date: Tue, 17 Jun 2025 15:06:07 +0200 Subject: [PATCH 052/216] rename variable --- src/pages/home/report/ReportActionsList.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 1ab16d9b2296..22e37116ec63 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -383,18 +383,18 @@ function ReportActionsList({ ); // Mark the report as read when the user initially opens the report and there are unread messages - const didMarkReportReadInitially = useRef(false); + const didMarkReportAsReadInitially = useRef(false); useEffect(() => { if (!isListInitiallyLoaded) { return; } - if (!isReportUnread || didMarkReportReadInitially.current) { - didMarkReportReadInitially.current = true; + if (!isReportUnread || didMarkReportAsReadInitially.current) { + didMarkReportAsReadInitially.current = true; return; } - didMarkReportReadInitially.current = true; + didMarkReportAsReadInitially.current = true; readNewestAction(report.reportID); }, [isListInitiallyLoaded, isReportUnread, report.reportID]); From b2bce904f402f02ba0af36e07b115fe3758bfab2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Jun 2025 15:16:29 +0200 Subject: [PATCH 053/216] feat: add oldestUnreadReportActionID to OpenReport --- src/ONYXKEYS.ts | 3 +++ src/libs/Middleware/Pagination.ts | 11 +++++++++++ src/pages/home/ReportScreen.tsx | 11 ++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e5a3ab00fa5d..35e040ed12dc 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -641,6 +641,8 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', + + REPORT_OLDEST_UNREAD_REPORT_ACTION_ID: 'reportOldestUnreadReportActionID_', }, /** List of Form ids */ @@ -1003,6 +1005,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED]: OnyxTypes.FundID; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist; [ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; + [ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID]: string; }; type OnyxValuesMapping = { diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index a34165e8d783..f3889a7196be 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -6,6 +6,7 @@ import Log from '@libs/Log'; import PaginationUtils from '@libs/PaginationUtils'; import CONST from '@src/CONST'; import type {OnyxCollectionKey, OnyxPagesKey, OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Request} from '@src/types/onyx'; import type {PaginatedRequest} from '@src/types/onyx/Request'; import type Middleware from './types'; @@ -133,6 +134,16 @@ const Pagination: Middleware = (requestResponse, request) => { value: mergedPages, }); + const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response && (response.oldestUnreadReportActionID as string); + + if (oldestUnreadReportActionID) { + response.onyxData.push({ + key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, + onyxMethod: Onyx.METHOD.SET, + value: oldestUnreadReportActionID, + }); + } + return Promise.resolve(response); }); }; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 882a025e0ccc..7ac9a30a4961 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -268,7 +268,16 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.accountID, canBeMissing: false}); const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.email, canBeMissing: false}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - const {reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionIDFromRoute); + + const [oldestUnreadReportActionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); + const { + reportActions: unfilteredReportActions, + linkedAction, + sortedAllReportActions, + hasNewerActions, + hasOlderActions, + } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? oldestUnreadReportActionID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`, {canBeMissing: true}); From 5d9bee1e966259ab73b648bf4a3fb21e6df3018d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Jun 2025 15:45:54 +0200 Subject: [PATCH 054/216] fix: initial unread message correct on empty cache --- src/libs/Middleware/Pagination.ts | 15 ++++++--------- src/pages/home/ReportScreen.tsx | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index f3889a7196be..402546e6ef05 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -134,15 +134,12 @@ const Pagination: Middleware = (requestResponse, request) => { value: mergedPages, }); - const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response && (response.oldestUnreadReportActionID as string); - - if (oldestUnreadReportActionID) { - response.onyxData.push({ - key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, - onyxMethod: Onyx.METHOD.SET, - value: oldestUnreadReportActionID, - }); - } + const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response && ((response.oldestUnreadReportActionID as string) ?? '-1'); + response.onyxData.push({ + key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, + onyxMethod: Onyx.METHOD.SET, + value: oldestUnreadReportActionID, + }); return Promise.resolve(response); }); diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 7ac9a30a4961..2e2b481fb3a5 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -5,6 +5,7 @@ import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 're import type {FlatList, ViewStyle} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import Banner from '@components/Banner'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; @@ -269,14 +270,24 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.email, canBeMissing: false}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - const [oldestUnreadReportActionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); + const [oldestUnreadReportActionID, setOldestUnreadReportActionID] = useState(); + const [oldestUnreadReportActionIDValueFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); const { reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, hasNewerActions, hasOlderActions, - } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? oldestUnreadReportActionID); + } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === '-1' ? undefined : oldestUnreadReportActionID)); + + useEffect(() => { + if (!!oldestUnreadReportActionID || !oldestUnreadReportActionIDValueFromOnyx) { + return; + } + + setOldestUnreadReportActionID(oldestUnreadReportActionIDValueFromOnyx); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, null); + }, [oldestUnreadReportActionID, oldestUnreadReportActionIDValueFromOnyx, reportID]); const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`, {canBeMissing: true}); @@ -777,7 +788,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // only one we will have in cache. const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && reportActions.length <= 1; const isInitiallyLoadingReportWhileOffline = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && isOffline; - const isReportReady = !isInitiallyLoadingReport && !isInitiallyLoadingReportWhileOffline; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. From d17dae619340480d7c19a979bacf03371b36234f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Jun 2025 15:46:00 +0200 Subject: [PATCH 055/216] Update ReportScreen.tsx --- src/pages/home/ReportScreen.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 2e2b481fb3a5..bc4839159c04 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -788,6 +788,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // only one we will have in cache. const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && reportActions.length <= 1; const isInitiallyLoadingReportWhileOffline = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && isOffline; + const isReportReady = !isInitiallyLoadingReport && !isInitiallyLoadingReportWhileOffline && !!oldestUnreadReportActionID; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. From 79474549c4f6c7a75be21663c1f948fc96fcb98c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Jun 2025 15:46:05 +0200 Subject: [PATCH 056/216] fix: ref types --- src/pages/home/ReportScreen.tsx | 2 +- src/pages/home/ReportScreenContext.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index bc4839159c04..5140e8a5d381 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -142,7 +142,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const firstRenderRef = useRef(true); const [firstRender, setFirstRender] = useState(true); const isSkippingOpenReport = useRef(false); - const flatListRef = useRef(null); + const flatListRef = useRef | null>(null); const {isBetaEnabled} = usePermissions(); const {isOffline} = useNetwork(); const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); diff --git a/src/pages/home/ReportScreenContext.ts b/src/pages/home/ReportScreenContext.ts index 17160d9e8a09..c6758744d3d9 100644 --- a/src/pages/home/ReportScreenContext.ts +++ b/src/pages/home/ReportScreenContext.ts @@ -13,7 +13,7 @@ type ReactionListRef = { isActiveReportAction: (actionID: number | string) => boolean; }; -type FlatListRefType = RefObject> | null; +type FlatListRefType = RefObject | null> | null; type ScrollPosition = {offset?: number}; From 40d2814b31ae7dac357cf19bb69755c312542655 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Jun 2025 15:48:25 +0200 Subject: [PATCH 057/216] fix: move Onyx operation to user action --- src/libs/actions/Report.ts | 13 +++++++++++++ src/pages/home/ReportScreen.tsx | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index ec9bbfa91480..9b9540ee99e6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -5618,6 +5618,18 @@ function changeReportPolicy(reportID: string, policyID: string) { } } +/** + * Resets the oldestUnreadReportActionID stored in Onyx once a report has been loaded, to prevent stale data. + * @param reportID - The ID of the report to reset the oldest unread report action ID for. + */ +function resetOldestUnreadReportActionID(reportID: string | undefined) { + if (!reportID) { + return; + } + + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, null); +} + export type {Video, GuidedSetupData, TaskForParameters, IntroSelected}; export { @@ -5726,4 +5738,5 @@ export { changeReportPolicy, removeFailedReport, openUnreportedExpense, + resetOldestUnreadReportActionID, }; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 5140e8a5d381..41000ba93403 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -5,7 +5,6 @@ import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 're import type {FlatList, ViewStyle} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; import Banner from '@components/Banner'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; @@ -77,6 +76,7 @@ import { navigateToConciergeChat, openReport, readNewestAction, + resetOldestUnreadReportActionID, subscribeToReportLeavingEvents, unsubscribeFromLeavingRoomReportChannel, updateLastVisitTime, @@ -286,7 +286,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { } setOldestUnreadReportActionID(oldestUnreadReportActionIDValueFromOnyx); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, null); + resetOldestUnreadReportActionID(reportID); }, [oldestUnreadReportActionID, oldestUnreadReportActionIDValueFromOnyx, reportID]); const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); From 181fa61e6d970886072e6c69432565b46c72aa4c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Jun 2025 16:16:49 +0200 Subject: [PATCH 058/216] fix: reset oldestUnreadReportActionID on new openReport --- src/libs/Middleware/Pagination.ts | 2 ++ src/libs/actions/Report.ts | 3 +++ src/pages/home/ReportScreen.tsx | 29 +++++++++++++++++++---------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 402546e6ef05..ea380592cb70 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -134,6 +134,8 @@ const Pagination: Middleware = (requestResponse, request) => { value: mergedPages, }); + // Stores the oldestUnreadReportActionID in Onyx to to allow fetching the correct page initially when a report is loaded. + // This value is reset once the report has finished loading. const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response && ((response.oldestUnreadReportActionID as string) ?? '-1'); response.onyxData.push({ key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9b9540ee99e6..ae7007d0ce4e 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -984,6 +984,9 @@ function openReport( return; } + // Reset the oldestUnreadReportActionID which will be replaced by the new value from the OpenReport API call. + resetOldestUnreadReportActionID(reportID); + const optimisticReport = reportActionsExist(reportID) ? {} : { diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 41000ba93403..de1cfdd96bdb 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -272,14 +272,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [oldestUnreadReportActionID, setOldestUnreadReportActionID] = useState(); const [oldestUnreadReportActionIDValueFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); - const { - reportActions: unfilteredReportActions, - linkedAction, - sortedAllReportActions, - hasNewerActions, - hasOlderActions, - } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === '-1' ? undefined : oldestUnreadReportActionID)); + // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. useEffect(() => { if (!!oldestUnreadReportActionID || !oldestUnreadReportActionIDValueFromOnyx) { return; @@ -289,6 +283,14 @@ function ReportScreen({route, navigation}: ReportScreenProps) { resetOldestUnreadReportActionID(reportID); }, [oldestUnreadReportActionID, oldestUnreadReportActionIDValueFromOnyx, reportID]); + const { + reportActions: unfilteredReportActions, + linkedAction, + sortedAllReportActions, + hasNewerActions, + hasOlderActions, + } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === '-1' ? undefined : oldestUnreadReportActionID)); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`, {canBeMissing: true}); @@ -492,6 +494,12 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [firstRender, shouldShowNotFoundLinkedAction, reportID, isOptimisticDelete, reportMetadata?.isLoadingInitialReportActions, userLeavingStatus, currentReportIDFormRoute], ); + const handleOpenReport = useCallback((...args) => { + // Reset the oldestUnreadReportActionID everytime the report is (newly) fetched + setOldestUnreadReportActionID(undefined); + openReport(...args); + }, []); + const fetchReport = useCallback(() => { if (reportMetadata.isOptimisticReport && report?.type === CONST.REPORT.TYPE.CHAT) { return; @@ -507,10 +515,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When we get here with a moneyRequestReportActionID and a transactionID from the route it means we don't have the transaction thread created yet // so we have to call OpenReport in a way that the transaction thread will be created and attached to the parentReportAction if (transactionID && currentUserEmail) { - openReport(reportIDFromRoute, '', [currentUserEmail], undefined, moneyRequestReportActionID, false, [], undefined, undefined, transactionID); + handleOpenReport(reportIDFromRoute, '', [currentUserEmail], undefined, moneyRequestReportActionID, false, [], undefined, undefined, transactionID); return; } - openReport(reportIDFromRoute, reportActionIDFromRoute); + handleOpenReport(reportIDFromRoute, reportActionIDFromRoute); }, [ reportMetadata.isOptimisticReport, report?.type, @@ -519,6 +527,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { route.params?.moneyRequestReportActionID, route.params?.transactionID, currentUserEmail, + handleOpenReport, reportIDFromRoute, reportActionIDFromRoute, ]); @@ -605,7 +614,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { if (!shouldUseNarrowLayout || !isFocused || prevIsFocused || !isChatThread(report) || !isHiddenForCurrentUser(report) || isTransactionThreadView) { return; } - openReport(reportID); + handleOpenReport(reportID); // We don't want to run this useEffect every time `report` is changed // Excluding shouldUseNarrowLayout from the dependency list to prevent re-triggering on screen resize events. From 8366ce9447693826eb409fe0b854b36b7e3387f3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 26 Jun 2025 01:06:26 +0200 Subject: [PATCH 059/216] refactor: simplify --- src/libs/API/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 7d196819e514..9a94249b1407 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -255,9 +255,7 @@ function paginate Date: Tue, 15 Jul 2025 13:52:16 +0700 Subject: [PATCH 060/216] fix: spelling --- src/pages/home/ReportScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4b5aa860009b..c3003a6ce8ff 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -503,7 +503,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { ); const handleOpenReport = useCallback((...args) => { - // Reset the oldestUnreadReportActionID everytime the report is (newly) fetched + // Reset the oldestUnreadReportActionID every time the report is (newly) fetched setOldestUnreadReportActionID(undefined); openReport(...args); }, []); From 968788aa5fe9daeb48c2d76f87c6893b267e4dee Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 15 Jul 2025 13:52:57 +0700 Subject: [PATCH 061/216] fix: use CONST.DEFAULT_NUMBER_ID instead of -1 --- src/libs/Middleware/Pagination.ts | 2 +- src/pages/home/ReportScreen.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 806300b68b3c..3ba96492fa68 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -136,7 +136,7 @@ const Pagination: Middleware = (requestResponse, request) => { // Stores the oldestUnreadReportActionID in Onyx to to allow fetching the correct page initially when a report is loaded. // This value is reset once the report has finished loading. - const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response && ((response.oldestUnreadReportActionID as string) ?? '-1'); + const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response && ((response.oldestUnreadReportActionID as string) ?? String(CONST.DEFAULT_NUMBER_ID)); response.onyxData.push({ key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, onyxMethod: Onyx.METHOD.SET, diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index c3003a6ce8ff..1e5e1265a818 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -305,7 +305,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { sortedAllReportActions, hasNewerActions, hasOlderActions, - } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === '-1' ? undefined : oldestUnreadReportActionID)); + } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === String(CONST.DEFAULT_NUMBER_ID) ? undefined : oldestUnreadReportActionID)); const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`, {canBeMissing: true}); From 96aebf1bbec23bb9abb34bf2021c295e20fa3764 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 15 Jul 2025 15:28:43 +0700 Subject: [PATCH 062/216] fix: eslint --- .../home/report/useReportUnreadMessageScrollTracking.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts index 01d54a07b192..31a134610224 100644 --- a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -1,5 +1,5 @@ +import type {RefObject} from 'react'; import {useState} from 'react'; -import type {MutableRefObject} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {readNewestAction} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -9,10 +9,10 @@ type Args = { reportID: string; /** The current offset of scrolling from either top or bottom of chat list */ - currentVerticalScrollingOffsetRef: MutableRefObject; + currentVerticalScrollingOffsetRef: RefObject; /** Ref for whether read action was skipped */ - readActionSkippedRef: MutableRefObject; + readActionSkippedRef: RefObject; /** The initial value for visibility of floating message button */ floatingMessageVisibleInitialValue: boolean; From 9762edadf9e5e508c77c60bdef71d75475d2a537 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 15 Jul 2025 17:13:32 +0700 Subject: [PATCH 063/216] pass down oldestUnreadReportActionID from backend --- src/pages/home/ReportScreen.tsx | 1 + src/pages/home/report/ReportActionsList.tsx | 8 ++++++-- src/pages/home/report/ReportActionsView.tsx | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 1e5e1265a818..784278f9a477 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -870,6 +870,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { report={report} reportActions={reportActions} isLoadingInitialReportActions={reportMetadata?.isLoadingInitialReportActions && !isTransactionThreadView} + oldestUnreadReportActionID={oldestUnreadReportActionID} hasNewerActions={hasNewerActions} hasOlderActions={hasOlderActions} parentReportAction={parentReportAction} diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 0e01538cdf92..4f71d9879844 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -80,6 +80,9 @@ type ReportActionsListProps = { /** The transaction thread report's parentReportAction */ parentReportActionForTransactionThread: OnyxEntry; + /** The oldest unread report action ID */ + oldestUnreadReportActionID?: string; + /** Sorted actions prepared for display */ sortedReportActions: OnyxTypes.ReportAction[]; @@ -148,6 +151,7 @@ function ReportActionsList({ loadNewerChats, loadOlderChats, hasNewerActions, + oldestUnreadReportActionID, onLayout, isComposerFullSize, listID, @@ -377,8 +381,8 @@ function ReportActionsList({ }, [report.reportID]); const initialScrollKey = useMemo(() => { - return linkedReportActionID ?? unreadMarkerReportActionID; - }, [linkedReportActionID, unreadMarkerReportActionID]); + return linkedReportActionID ?? oldestUnreadReportActionID ?? unreadMarkerReportActionID; + }, [linkedReportActionID, oldestUnreadReportActionID, unreadMarkerReportActionID]); const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); const handleListInitiallyLoaded = useCallback(() => { diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index b5f691b12e05..f7d931eca652 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -50,6 +50,9 @@ type ReportActionsViewProps = { /** The report's parentReportAction */ parentReportAction: OnyxEntry; + /** The oldest unread report action ID */ + oldestUnreadReportActionID?: string; + /** The report metadata loading states */ isLoadingInitialReportActions?: boolean; @@ -77,6 +80,7 @@ function ReportActionsView({ transactionThreadReportID, hasNewerActions, hasOlderActions, + oldestUnreadReportActionID, isReportTransactionThread, }: ReportActionsViewProps) { useCopySelectionHelper(); @@ -305,6 +309,7 @@ function ReportActionsView({ parentReportAction={parentReportAction} parentReportActionForTransactionThread={parentReportActionForTransactionThread} onLayout={recordTimeToMeasureItemLayout} + oldestUnreadReportActionID={oldestUnreadReportActionID} sortedReportActions={reportActions} sortedVisibleReportActions={visibleReportActions} mostRecentIOUReportActionID={mostRecentIOUReportActionID} From d14c710e4683204c03b8c94ac9c3291d4a92e257 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 15 Jul 2025 17:22:22 +0700 Subject: [PATCH 064/216] fix: extract not found id --- src/CONST/index.ts | 1 + src/libs/Middleware/Pagination.ts | 19 +++++++++++-------- src/pages/home/ReportScreen.tsx | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e45a2a945acd..295e0d2ae681 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -891,6 +891,7 @@ const CONST = { USE_EXPENSIFY_URL, EXPENSIFY_URL, EXPENSIFY_MOBILE_URL, + NOT_FOUND_ID: '-1', GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', IMAGE_BASE64_MATCH: 'base64', diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 3ba96492fa68..261724eb3935 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -1,6 +1,7 @@ import fastMerge from 'expensify-common/dist/fastMerge'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import {WRITE_COMMANDS} from '@libs/API/types'; import type {ApiCommand} from '@libs/API/types'; import Log from '@libs/Log'; import PaginationUtils from '@libs/PaginationUtils'; @@ -134,14 +135,16 @@ const Pagination: Middleware = (requestResponse, request) => { value: mergedPages, }); - // Stores the oldestUnreadReportActionID in Onyx to to allow fetching the correct page initially when a report is loaded. - // This value is reset once the report has finished loading. - const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response && ((response.oldestUnreadReportActionID as string) ?? String(CONST.DEFAULT_NUMBER_ID)); - response.onyxData.push({ - key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, - onyxMethod: Onyx.METHOD.SET, - value: oldestUnreadReportActionID, - }); + if (request.command === WRITE_COMMANDS.OPEN_REPORT) { + // Stores the oldestUnreadReportActionID in Onyx to to allow fetching the correct page initially when a report is loaded. + // This value is reset once the report has finished loading. + const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response ? ((response.oldestUnreadReportActionID as string) ?? CONST.NOT_FOUND_ID) : CONST.NOT_FOUND_ID; + response.onyxData.push({ + key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, + onyxMethod: Onyx.METHOD.SET, + value: oldestUnreadReportActionID, + }); + } return Promise.resolve(response); }); diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 784278f9a477..45c27862f13b 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -305,7 +305,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { sortedAllReportActions, hasNewerActions, hasOlderActions, - } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === String(CONST.DEFAULT_NUMBER_ID) ? undefined : oldestUnreadReportActionID)); + } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === CONST.NOT_FOUND_ID ? undefined : oldestUnreadReportActionID)); const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`, {canBeMissing: true}); From e31d69a0e8a9bed9bbe6da7015d94327e8a6e0cd Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 4 Aug 2025 15:17:26 +0200 Subject: [PATCH 065/216] fix: loading chats in offline mode --- src/libs/Middleware/Pagination.ts | 4 +++- src/pages/home/ReportScreen.tsx | 3 ++- src/types/onyx/Response.ts | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 261724eb3935..20b3491d7b5f 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -138,7 +138,9 @@ const Pagination: Middleware = (requestResponse, request) => { if (request.command === WRITE_COMMANDS.OPEN_REPORT) { // Stores the oldestUnreadReportActionID in Onyx to to allow fetching the correct page initially when a report is loaded. // This value is reset once the report has finished loading. - const oldestUnreadReportActionID = 'oldestUnreadReportActionID' in response ? ((response.oldestUnreadReportActionID as string) ?? CONST.NOT_FOUND_ID) : CONST.NOT_FOUND_ID; + const oldestUnreadReportActionID = response.oldestUnreadReportActionID ?? CONST.NOT_FOUND_ID; + console.log('oldestUnreadReportActionID pag', oldestUnreadReportActionID); + response.onyxData.push({ key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, onyxMethod: Onyx.METHOD.SET, diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index f9c4e70f3394..8be8e8b6dbe7 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -807,7 +807,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // only one we will have in cache. const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && reportActions.length <= 1; const isInitiallyLoadingReportWhileOffline = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && isOffline; - const isReportReady = !isInitiallyLoadingReport && !isInitiallyLoadingReportWhileOffline && !!oldestUnreadReportActionID; + const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionID; + const isReportReady = !isInitiallyLoadingReport && !isInitiallyLoadingReportWhileOffline && !isLoadingOldestUnreadReportActionID; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 5823edc025d6..7f454c36a86b 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -87,6 +87,9 @@ type Response = { /** If there is newer data to load for pagination commands */ hasNewerActions?: boolean; + /** The oldest unread report action ID */ + oldestUnreadReportActionID?: string; + /** The email of the original user (returned when in delegate mode) */ requesterEmail?: string; From c5925c325bac485a81a13ff584ce8edd368a0517 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 4 Aug 2025 15:29:42 +0200 Subject: [PATCH 066/216] fix: remove console log --- src/libs/Middleware/Pagination.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 20b3491d7b5f..b5eeb2b9b0cd 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -139,8 +139,6 @@ const Pagination: Middleware = (requestResponse, request) => { // Stores the oldestUnreadReportActionID in Onyx to to allow fetching the correct page initially when a report is loaded. // This value is reset once the report has finished loading. const oldestUnreadReportActionID = response.oldestUnreadReportActionID ?? CONST.NOT_FOUND_ID; - console.log('oldestUnreadReportActionID pag', oldestUnreadReportActionID); - response.onyxData.push({ key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, onyxMethod: Onyx.METHOD.SET, From 9affe08b94f8b1a1fded8d70b2fde437ba581b3a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 5 Aug 2025 12:31:16 +0200 Subject: [PATCH 067/216] refactor: add default boolean value --- src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 7fce92d3b5eb..b93205785571 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -34,7 +34,7 @@ const AUTOSCROLL_TO_TOP_THRESHOLD = 250; function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) { const { - shouldEnableAutoScrollToTopThreshold, + shouldEnableAutoScrollToTopThreshold = false, initialScrollKey, data, onStartReached, From 4ce35d6d9842634d70a881e1fa842e0ded82e121 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 5 Aug 2025 16:07:40 +0200 Subject: [PATCH 068/216] refactor: remove unused code --- jest/setup.ts | 2 -- .../BaseInvertedFlatList/RenderTaskQueue.tsx | 7 ------- 2 files changed, 9 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 4af4de89c7d3..9155d6ae00ff 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -127,8 +127,6 @@ jest.mock( this.handler = handler; } - setOnEndReached() {} - cancel() {} }, ); diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx index 35f688302615..723fe7c6332b 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx @@ -11,8 +11,6 @@ class RenderTaskQueue { private handler: ((info: RenderInfo) => void) | undefined = undefined; - private onEndReached: (() => void) | undefined = undefined; - private timeout: NodeJS.Timeout | null = null; add(info: RenderInfo, startRendering = true) { @@ -34,10 +32,6 @@ class RenderTaskQueue { this.handler = handler; } - setOnEndReached(onEndReached: (() => void) | undefined) { - this.onEndReached = onEndReached; - } - cancel() { this.isRendering = false; if (this.timeout == null) { @@ -49,7 +43,6 @@ class RenderTaskQueue { private render() { const info = this.renderInfos.shift(); if (!info) { - this.onEndReached?.(); this.isRendering = false; return; } From 9bf54880023aa9102f67b99e1f8b7b83fe90c8b0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 5 Aug 2025 16:07:57 +0200 Subject: [PATCH 069/216] refactor: rename variable --- src/pages/home/report/ReportActionsView.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 25379814b55a..5b15263e126d 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -88,15 +88,15 @@ function ReportActionsView({ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const prevTransactionThreadReport = usePrevious(transactionThreadReport); - const reportActionID = route?.params?.reportActionID; - const prevReportActionID = usePrevious(reportActionID); + const reportActionIDFromRoute = route?.params?.reportActionID; + const prevReportActionIDFromRoute = usePrevious(reportActionIDFromRoute); const reportPreviewAction = useMemo(() => getReportPreviewAction(report.chatReportID, report.reportID), [report.chatReportID, report.reportID]); const didLayout = useRef(false); const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isFocused = useIsFocused(); - const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(!!reportActionID); + const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(!!reportActionIDFromRoute); const prevShouldUseNarrowLayoutRef = useRef(shouldUseNarrowLayout); const reportID = report.reportID; const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]); @@ -108,15 +108,15 @@ function ReportActionsView({ useEffect(() => { // When we linked to message - we do not need to wait for initial actions - they already exists - if (!reportActionID || !isOffline) { + if (!reportActionIDFromRoute || !isOffline) { return; } updateLoadingInitialReportAction(report.reportID); - }, [isOffline, report.reportID, reportActionID]); + }, [isOffline, report.reportID, reportActionIDFromRoute]); // Change the list ID only for comment linking to get the positioning right const listID = useMemo(() => { - if (!reportActionID && !prevReportActionID) { + if (!reportActionIDFromRoute && !prevReportActionIDFromRoute) { // Keep the old list ID since we're not in the Comment Linking flow return listOldID; } @@ -126,7 +126,7 @@ function ReportActionsView({ return newID; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [route, reportActionID]); + }, [route, reportActionIDFromRoute]); // When we are offline before opening an IOU/Expense report, // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. @@ -255,7 +255,7 @@ function ReportActionsView({ }, []); // Check if the first report action in the list is the one we're currently linked to - const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; + const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionIDFromRoute; useEffect(() => { let timerID: NodeJS.Timeout; @@ -294,7 +294,7 @@ function ReportActionsView({ } // AutoScroll is disabled when we do linking to a specific reportAction - const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); + const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionIDFromRoute || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); return ( <> Date: Tue, 5 Aug 2025 17:15:36 +0200 Subject: [PATCH 070/216] fix: add comments --- .../InvertedFlatList/BaseInvertedFlatList/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index b93205785571..071025f81b46 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -95,6 +95,10 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa [onContentSizeChange], ); + // When we are initially showing a message on the first page of the whole dataset, + // we don't want to immediately start rendering the list. + // Instead, we wait for the initial data to be displayed, scroll to the item manually and + // then start rendering more items. useEffect(() => { if (didScroll.current || !isMessageOnFirstPage.current || !didInitialContentRender) { return; @@ -130,6 +134,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa const handleStartReached = useCallback( (info: RenderInfo) => { + // Same as above, we want to prevent rendering more items until the linked item on the first page has been scrolled to. const startRendering = didScroll.current || !isMessageOnFirstPage.current; renderQueue.add(info, startRendering); }, From 55a18d14596d451116c31bedcd96df06a6efb11f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 Aug 2025 11:29:08 +0200 Subject: [PATCH 071/216] fix: readNewestAction logic after merge --- src/pages/home/report/ReportActionsList.tsx | 23 +++++---------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index ffc554e5e517..5cef8cfca2ae 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -419,33 +419,20 @@ function ReportActionsList({ return; } - if (isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction))) { + if (isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions))) { // On desktop, when the notification center is displayed, isVisible will return false. // 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) { + const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; + + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { readNewestAction(report.reportID); if (isFromNotification) { Navigation.setParams({referrer: undefined}); } - } else { - readActionSkipped.current = true; - } - } - - // On desktop, when the notification center is displayed, isVisible will return false. - // 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; - const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; - - if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { - readNewestAction(report.reportID); - if (isFromNotification) { - Navigation.setParams({referrer: undefined}); + return; } - return; } readActionSkipped.current = true; From 6b0097ae45345f38f5e07ecff1c1ec95e6981179 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 Aug 2025 11:35:34 +0200 Subject: [PATCH 072/216] fix: simplify if statement --- src/pages/home/report/ReportActionsList.tsx | 30 ++++++++++++--------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 5cef8cfca2ae..a978019e73a0 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -419,23 +419,27 @@ function ReportActionsList({ return; } - if (isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions))) { - // On desktop, when the notification center is displayed, isVisible will return false. - // 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; - const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; - - if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { - readNewestAction(report.reportID); - if (isFromNotification) { - Navigation.setParams({referrer: undefined}); - } - return; + const isLastActionUnread = lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); + if (!isUnread(report, transactionThreadReport) && !isLastActionUnread) { + return; + } + + // On desktop, when the notification center is displayed, isVisible will return false. + // 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; + const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD; + + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { + readNewestAction(report.reportID); + if (isFromNotification) { + Navigation.setParams({referrer: undefined}); } + return; } readActionSkipped.current = true; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, isListInitiallyLoaded, hasNewerActions]); From 22539e66bf90ec1e86609ccce54a47681b26cb02 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 Aug 2025 18:38:54 +0200 Subject: [PATCH 073/216] refactor: simplify report ready conditions --- src/pages/home/ReportScreen.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 6d085add62bf..58417d46c296 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -803,12 +803,16 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [reportMetadata?.isLoadingInitialReportActions, reportMetadata?.hasOnceLoadedReportActions], ); - // When opening an unread report, it is very likely that the message we will open to is not the latest, which is the - // only one we will have in cache. - const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && reportActions.length <= 1; - const isInitiallyLoadingReportWhileOffline = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && isOffline; - const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionID; - const isReportReady = !isInitiallyLoadingReport && !isInitiallyLoadingReportWhileOffline && !isLoadingOldestUnreadReportActionID; + // When opening an unread report, it is very likely that the message we will open to is not the latest, + // which is the only one we will have in cache. + const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && (isOffline || reportActions.length <= 1); + + // When we open a report, we have to wait for the oldest unread report action ID to be set and + // retrieved from Onyx, in order to get the correct initial report action page from store. + const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionID && !reportMetadata.isLoadingInitialReportActions; + + // Once all the above conditions are met, we can consider the report ready. + const isReportReady = !isInitiallyLoadingReport && !isLoadingOldestUnreadReportActionID; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. From 04a26151a95de6cc7384a2adbfba2282cff01825 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 Aug 2025 18:38:57 +0200 Subject: [PATCH 074/216] Update Pagination.ts --- src/libs/Middleware/Pagination.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index b5eeb2b9b0cd..671a29c1c805 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -140,8 +140,8 @@ const Pagination: Middleware = (requestResponse, request) => { // This value is reset once the report has finished loading. const oldestUnreadReportActionID = response.oldestUnreadReportActionID ?? CONST.NOT_FOUND_ID; response.onyxData.push({ - key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, value: oldestUnreadReportActionID, }); } From 1167533c65bb56a52c7863aa9eba001dbfc20f6c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 8 Aug 2025 18:39:14 +0200 Subject: [PATCH 075/216] fix: update oldestUnreadReportActionID type --- src/types/onyx/Response.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 7f454c36a86b..f00acfcafb1f 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -88,7 +88,7 @@ type Response = { hasNewerActions?: boolean; /** The oldest unread report action ID */ - oldestUnreadReportActionID?: string; + oldestUnreadReportActionID?: string | null; /** The email of the original user (returned when in delegate mode) */ requesterEmail?: string; From 5f02df79edb480a7cab089cf78db1c9f7f8cc839 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 14:39:35 +0200 Subject: [PATCH 076/216] fix: don't reset oldestUnreadReportActionID on every openReport call --- src/libs/actions/Report.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 116dfe4a3723..94a21bd1193d 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -977,9 +977,6 @@ function openReport( return; } - // Reset the oldestUnreadReportActionID which will be replaced by the new value from the OpenReport API call. - resetOldestUnreadReportActionID(reportID); - const optimisticReport = reportActionsExist(reportID) ? {} : { From 5297ea024d8c3024b49ea583accbd7e40520cde1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 14:40:02 +0200 Subject: [PATCH 077/216] fix: use onyx value if available on first render --- src/pages/home/ReportScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 58417d46c296..7caddb9c3fdd 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -271,12 +271,12 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.email, canBeMissing: false}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - const [oldestUnreadReportActionID, setOldestUnreadReportActionID] = useState(); const [oldestUnreadReportActionIDValueFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); + const [oldestUnreadReportActionID, setOldestUnreadReportActionID] = useState(oldestUnreadReportActionIDValueFromOnyx); // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. useEffect(() => { - if (!!oldestUnreadReportActionID || !oldestUnreadReportActionIDValueFromOnyx) { + if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionID)) { return; } From 6262c53ab7f76c456ce603f6aa82ce4f9482496e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 14:40:17 +0200 Subject: [PATCH 078/216] fix: isReportReady condition --- src/pages/home/ReportScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 7caddb9c3fdd..f174857ccdf2 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -805,11 +805,11 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. - const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && (reportMetadata.isLoadingInitialReportActions ?? false) && (isOffline || reportActions.length <= 1); + const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); // When we open a report, we have to wait for the oldest unread report action ID to be set and // retrieved from Onyx, in order to get the correct initial report action page from store. - const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionID && !reportMetadata.isLoadingInitialReportActions; + const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionID; // Once all the above conditions are met, we can consider the report ready. const isReportReady = !isInitiallyLoadingReport && !isLoadingOldestUnreadReportActionID; From 5c9188ecfd5634e0f7106d7ad7baf0186c55e11b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 14:40:38 +0200 Subject: [PATCH 079/216] Update PaginationTest.tsx --- tests/ui/PaginationTest.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 3f41a8b09a67..8b8386a3368d 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -93,6 +93,7 @@ function mockOpenReport(messageCount: number, initialID: string) { : [], hasOlderActions: !comments['1'], hasNewerActions: !!reportActionID, + oldestUnreadReportActionID: null, }; }); } @@ -170,6 +171,8 @@ async function signInAndGetApp(): Promise { type: CONST.REPORT.TYPE.CHAT, }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${REPORT_ID}`, '-1'); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'), }); @@ -185,6 +188,8 @@ async function signInAndGetApp(): Promise { type: CONST.REPORT.TYPE.CHAT, }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${COMMENT_LINKING_REPORT_ID}`, '-1'); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${COMMENT_LINKING_REPORT_ID}`, { '100': buildCreatedAction('100', format(TEN_MINUTES_AGO, CONST.DATE.FNS_DB_FORMAT_STRING)), '101': { From 45e0f660a4be4fdd8d6005e94bbc72bc5bc2955c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 15:11:15 +0200 Subject: [PATCH 080/216] Update UnreadIndicatorsTest.tsx --- tests/ui/UnreadIndicatorsTest.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 79195813bdde..4c0c2e55a252 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -162,6 +162,9 @@ function signInAndGetAppWithUnreadChat(): Promise { lastActorAccountID: USER_B_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, }); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${REPORT_ID}`, '-1'); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { [createdReportActionID]: createdReportAction, 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1'), From 16fb1a10ff411e22ee5f207900ab7b6e6ddbf2ab Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 15:11:24 +0200 Subject: [PATCH 081/216] Update useReportUnreadMessageScrollTracking.ts --- src/pages/home/report/useReportUnreadMessageScrollTracking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts index 67d313755d87..4721a5db4008 100644 --- a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -60,7 +60,7 @@ export default function useReportUnreadMessageScrollTracking({ const isScrolledToEnd = currentVerticalScrollingOffsetRef.current <= CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD; // When we have an unread message, display floating button if we're scrolled more than the offset - if (!hasUnreadMarkerReportAction && !isScrolledToEnd && !isFloatingMessageCounterVisible) { + if (!isScrolledToEnd && !hasUnreadMarkerReportAction && !isFloatingMessageCounterVisible) { setIsFloatingMessageCounterVisible(true); } From 742bcb3f0c32574707f750cebd641eaf55e39be2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 15:13:51 +0200 Subject: [PATCH 082/216] fix: prettier --- src/pages/home/report/useReportUnreadMessageScrollTracking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts index 4721a5db4008..4d4e1aeca1e6 100644 --- a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -60,7 +60,7 @@ export default function useReportUnreadMessageScrollTracking({ const isScrolledToEnd = currentVerticalScrollingOffsetRef.current <= CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD; // When we have an unread message, display floating button if we're scrolled more than the offset - if (!isScrolledToEnd && !hasUnreadMarkerReportAction && !isFloatingMessageCounterVisible) { + if (!isScrolledToEnd && !hasUnreadMarkerReportAction && !isFloatingMessageCounterVisible) { setIsFloatingMessageCounterVisible(true); } From 1c11181eedd49cfcbc51ba6b5418255700b0854a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 11 Aug 2025 20:08:33 +0200 Subject: [PATCH 083/216] refactor: simplify maintainVisibleContentPosition variable --- .../BaseInvertedFlatList/index.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 071025f81b46..00af11b1ac7c 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -149,17 +149,14 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa [renderItem, dataIndexDifference], ); - const maintainVisibleContentPosition = useMemo(() => { - const config: ScrollViewProps['maintainVisibleContentPosition'] = { + const maintainVisibleContentPosition = useMemo(() => { + const enableAutoScrollToTopThreshold = shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData; + + return { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, + minIndexForVisible: data.length ? 0 : 0, + autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, }; - - if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { - config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; - } - - return config; }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); useImperativeHandle(ref, () => { From 85093ee0ebde8720ef0c68498b4e912c65d4e6a7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 13 Aug 2025 10:39:58 +0200 Subject: [PATCH 084/216] refactor: improve props --- .../ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx | 6 ++++-- src/components/ActionSheetAwareScrollView/index.ios.tsx | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index cf96a0823b0f..b3d1ca85436e 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -65,7 +65,7 @@ type ActionSheetKeyboardSpaceProps = ViewProps & { position?: SharedValue; }; -function ActionSheetKeyboardSpace(props: ActionSheetKeyboardSpaceProps) { +function ActionSheetKeyboardSpace({children, ...props}: ActionSheetKeyboardSpaceProps) { const styles = useThemeStyles(); const { unmodifiedPaddings: {top: paddingTop = 0, bottom: paddingBottom = 0}, @@ -258,7 +258,9 @@ function ActionSheetKeyboardSpace(props: ActionSheetKeyboardSpaceProps) { style={[styles.flex1, animatedStyle]} // eslint-disable-next-line react/jsx-props-no-spreading {...props} - /> + > + {children} + ); } diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx index 9c465b966daf..cd81796c8c27 100644 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -6,7 +6,7 @@ import Reanimated, {useAnimatedRef, useScrollViewOffset} from 'react-native-rean import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; -const ActionSheetAwareScrollView = forwardRef>((props, ref) => { +const ActionSheetAwareScrollView = forwardRef>(({children, ...props}, ref) => { const scrollViewAnimatedRef = useAnimatedRef(); const position = useScrollViewOffset(scrollViewAnimatedRef); @@ -30,7 +30,7 @@ const ActionSheetAwareScrollView = forwardRef - {props.children} + {children} ); }); From b70a04318c3334fd01e1094ba335bc102d1c2f16 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 12:22:53 +0200 Subject: [PATCH 085/216] fix: animate container rather then children in `ActionSheetAwareScrollView` --- .../ActionSheetAwareScrollView/index.ios.tsx | 26 ++++++++----- ....tsx => useActionSheetKeyboardSpacing.tsx} | 38 +++++-------------- 2 files changed, 25 insertions(+), 39 deletions(-) rename src/components/ActionSheetAwareScrollView/{ActionSheetKeyboardSpace.tsx => useActionSheetKeyboardSpacing.tsx} (89%) diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx index cd81796c8c27..ce223dbdd028 100644 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -2,13 +2,12 @@ import type {PropsWithChildren} from 'react'; import React, {forwardRef, useCallback} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView, ScrollViewProps} from 'react-native'; -import Reanimated, {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated'; +import Reanimated, {useAnimatedRef, useAnimatedStyle} from 'react-native-reanimated'; import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; -import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; +import useActionSheetKeyboardSpacing from './useActionSheetKeyboardSpacing'; const ActionSheetAwareScrollView = forwardRef>(({children, ...props}, ref) => { const scrollViewAnimatedRef = useAnimatedRef(); - const position = useScrollViewOffset(scrollViewAnimatedRef); const onRef = useCallback( (assignedRef: Reanimated.ScrollView) => { @@ -24,14 +23,21 @@ const ActionSheetAwareScrollView = forwardRef ({ + paddingBottom: spacing.get(), + })); + return ( - - {children} - + + + {children} + + ); }); diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx similarity index 89% rename from src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx rename to src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx index b3d1ca85436e..fd8c2074cc99 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx @@ -1,10 +1,9 @@ -import React, {useContext, useEffect} from 'react'; -import type {ViewProps} from 'react-native'; +import {useContext, useEffect} from 'react'; import {useKeyboardHandler} from 'react-native-keyboard-controller'; -import Reanimated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; -import type {SharedValue} from 'react-native-reanimated'; +import type Reanimated from 'react-native-reanimated'; +import {useAnimatedReaction, useDerivedValue, useScrollViewOffset, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; +import type {AnimatedRef} from 'react-native-reanimated'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; -import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {Actions, ActionSheetAwareScrollViewContext, States} from './ActionSheetAwareScrollViewContext'; @@ -60,18 +59,13 @@ const useAnimatedKeyboard = () => { return {state, height, heightWhenOpened}; }; -type ActionSheetKeyboardSpaceProps = ViewProps & { - /** scroll offset of the parent ScrollView */ - position?: SharedValue; -}; +function useActionSheetKeyboardSpacing(scrollViewAnimatedRef: AnimatedRef) { + const position = useScrollViewOffset(scrollViewAnimatedRef); -function ActionSheetKeyboardSpace({children, ...props}: ActionSheetKeyboardSpaceProps) { - const styles = useThemeStyles(); const { unmodifiedPaddings: {top: paddingTop = 0, bottom: paddingBottom = 0}, } = useSafeAreaPaddings(); const keyboard = useAnimatedKeyboard(); - const {position} = props; // Similar to using `global` in worklet but it's just a local object const syncLocalWorkletState = useSharedValue(KeyboardState.UNKNOWN); @@ -102,7 +96,7 @@ function ActionSheetKeyboardSpace({children, ...props}: ActionSheetKeyboardSpace [], ); - const translateY = useDerivedValue(() => { + const spacing = useDerivedValue(() => { const {current, previous} = currentActionSheetState.get(); // We don't need to run any additional logic. it will always return 0 for idle state @@ -249,21 +243,7 @@ function ActionSheetKeyboardSpace({children, ...props}: ActionSheetKeyboardSpace } }, []); - const animatedStyle = useAnimatedStyle(() => ({ - paddingTop: translateY.get(), - })); - - return ( - - {children} - - ); + return spacing; } -ActionSheetKeyboardSpace.displayName = 'ActionSheetKeyboardSpace'; - -export default ActionSheetKeyboardSpace; +export default useActionSheetKeyboardSpacing; From db95f6f1a15d983151008b7eb74205bcac040e4c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 12:59:31 +0200 Subject: [PATCH 086/216] Revert "fix: animate container rather then children in `ActionSheetAwareScrollView`" This reverts commit b779b5c07dbbe680e23307ce5c66730d896636f5. --- ...acing.tsx => ActionSheetKeyboardSpace.tsx} | 36 ++++++++++++++----- .../ActionSheetAwareScrollView/index.ios.tsx | 28 ++++++--------- 2 files changed, 38 insertions(+), 26 deletions(-) rename src/components/ActionSheetAwareScrollView/{useActionSheetKeyboardSpacing.tsx => ActionSheetKeyboardSpace.tsx} (90%) diff --git a/src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx similarity index 90% rename from src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx rename to src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index fd8c2074cc99..cf96a0823b0f 100644 --- a/src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -1,9 +1,10 @@ -import {useContext, useEffect} from 'react'; +import React, {useContext, useEffect} from 'react'; +import type {ViewProps} from 'react-native'; import {useKeyboardHandler} from 'react-native-keyboard-controller'; -import type Reanimated from 'react-native-reanimated'; -import {useAnimatedReaction, useDerivedValue, useScrollViewOffset, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; -import type {AnimatedRef} from 'react-native-reanimated'; +import Reanimated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; +import type {SharedValue} from 'react-native-reanimated'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {Actions, ActionSheetAwareScrollViewContext, States} from './ActionSheetAwareScrollViewContext'; @@ -59,13 +60,18 @@ const useAnimatedKeyboard = () => { return {state, height, heightWhenOpened}; }; -function useActionSheetKeyboardSpacing(scrollViewAnimatedRef: AnimatedRef) { - const position = useScrollViewOffset(scrollViewAnimatedRef); +type ActionSheetKeyboardSpaceProps = ViewProps & { + /** scroll offset of the parent ScrollView */ + position?: SharedValue; +}; +function ActionSheetKeyboardSpace(props: ActionSheetKeyboardSpaceProps) { + const styles = useThemeStyles(); const { unmodifiedPaddings: {top: paddingTop = 0, bottom: paddingBottom = 0}, } = useSafeAreaPaddings(); const keyboard = useAnimatedKeyboard(); + const {position} = props; // Similar to using `global` in worklet but it's just a local object const syncLocalWorkletState = useSharedValue(KeyboardState.UNKNOWN); @@ -96,7 +102,7 @@ function useActionSheetKeyboardSpacing(scrollViewAnimatedRef: AnimatedRef { + const translateY = useDerivedValue(() => { const {current, previous} = currentActionSheetState.get(); // We don't need to run any additional logic. it will always return 0 for idle state @@ -243,7 +249,19 @@ function useActionSheetKeyboardSpacing(scrollViewAnimatedRef: AnimatedRef ({ + paddingTop: translateY.get(), + })); + + return ( + + ); } -export default useActionSheetKeyboardSpacing; +ActionSheetKeyboardSpace.displayName = 'ActionSheetKeyboardSpace'; + +export default ActionSheetKeyboardSpace; diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx index ce223dbdd028..9c465b966daf 100644 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -2,12 +2,13 @@ import type {PropsWithChildren} from 'react'; import React, {forwardRef, useCallback} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView, ScrollViewProps} from 'react-native'; -import Reanimated, {useAnimatedRef, useAnimatedStyle} from 'react-native-reanimated'; +import Reanimated, {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated'; import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; -import useActionSheetKeyboardSpacing from './useActionSheetKeyboardSpacing'; +import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; -const ActionSheetAwareScrollView = forwardRef>(({children, ...props}, ref) => { +const ActionSheetAwareScrollView = forwardRef>((props, ref) => { const scrollViewAnimatedRef = useAnimatedRef(); + const position = useScrollViewOffset(scrollViewAnimatedRef); const onRef = useCallback( (assignedRef: Reanimated.ScrollView) => { @@ -23,21 +24,14 @@ const ActionSheetAwareScrollView = forwardRef ({ - paddingBottom: spacing.get(), - })); - return ( - - - {children} - - + + {props.children} + ); }); From 28606cafb0122ca8f93bbe825845c260a0486b49 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 12:22:53 +0200 Subject: [PATCH 087/216] fix: animate container rather then children in `ActionSheetAwareScrollView` --- .../ActionSheetAwareScrollView/index.ios.tsx | 28 +++++++++------ ....tsx => useActionSheetKeyboardSpacing.tsx} | 36 +++++-------------- 2 files changed, 26 insertions(+), 38 deletions(-) rename src/components/ActionSheetAwareScrollView/{ActionSheetKeyboardSpace.tsx => useActionSheetKeyboardSpacing.tsx} (90%) diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx index 9c465b966daf..ce223dbdd028 100644 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -2,13 +2,12 @@ import type {PropsWithChildren} from 'react'; import React, {forwardRef, useCallback} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView, ScrollViewProps} from 'react-native'; -import Reanimated, {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated'; +import Reanimated, {useAnimatedRef, useAnimatedStyle} from 'react-native-reanimated'; import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; -import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; +import useActionSheetKeyboardSpacing from './useActionSheetKeyboardSpacing'; -const ActionSheetAwareScrollView = forwardRef>((props, ref) => { +const ActionSheetAwareScrollView = forwardRef>(({children, ...props}, ref) => { const scrollViewAnimatedRef = useAnimatedRef(); - const position = useScrollViewOffset(scrollViewAnimatedRef); const onRef = useCallback( (assignedRef: Reanimated.ScrollView) => { @@ -24,14 +23,21 @@ const ActionSheetAwareScrollView = forwardRef ({ + paddingBottom: spacing.get(), + })); + return ( - - {props.children} - + + + {children} + + ); }); diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx similarity index 90% rename from src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx rename to src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx index cf96a0823b0f..fd8c2074cc99 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.tsx @@ -1,10 +1,9 @@ -import React, {useContext, useEffect} from 'react'; -import type {ViewProps} from 'react-native'; +import {useContext, useEffect} from 'react'; import {useKeyboardHandler} from 'react-native-keyboard-controller'; -import Reanimated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; -import type {SharedValue} from 'react-native-reanimated'; +import type Reanimated from 'react-native-reanimated'; +import {useAnimatedReaction, useDerivedValue, useScrollViewOffset, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; +import type {AnimatedRef} from 'react-native-reanimated'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; -import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {Actions, ActionSheetAwareScrollViewContext, States} from './ActionSheetAwareScrollViewContext'; @@ -60,18 +59,13 @@ const useAnimatedKeyboard = () => { return {state, height, heightWhenOpened}; }; -type ActionSheetKeyboardSpaceProps = ViewProps & { - /** scroll offset of the parent ScrollView */ - position?: SharedValue; -}; +function useActionSheetKeyboardSpacing(scrollViewAnimatedRef: AnimatedRef) { + const position = useScrollViewOffset(scrollViewAnimatedRef); -function ActionSheetKeyboardSpace(props: ActionSheetKeyboardSpaceProps) { - const styles = useThemeStyles(); const { unmodifiedPaddings: {top: paddingTop = 0, bottom: paddingBottom = 0}, } = useSafeAreaPaddings(); const keyboard = useAnimatedKeyboard(); - const {position} = props; // Similar to using `global` in worklet but it's just a local object const syncLocalWorkletState = useSharedValue(KeyboardState.UNKNOWN); @@ -102,7 +96,7 @@ function ActionSheetKeyboardSpace(props: ActionSheetKeyboardSpaceProps) { [], ); - const translateY = useDerivedValue(() => { + const spacing = useDerivedValue(() => { const {current, previous} = currentActionSheetState.get(); // We don't need to run any additional logic. it will always return 0 for idle state @@ -249,19 +243,7 @@ function ActionSheetKeyboardSpace(props: ActionSheetKeyboardSpaceProps) { } }, []); - const animatedStyle = useAnimatedStyle(() => ({ - paddingTop: translateY.get(), - })); - - return ( - - ); + return spacing; } -ActionSheetKeyboardSpace.displayName = 'ActionSheetKeyboardSpace'; - -export default ActionSheetKeyboardSpace; +export default useActionSheetKeyboardSpacing; From 2d82c4e32dd7977dff1b387255982ec8c1c382b3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 15:20:06 +0200 Subject: [PATCH 088/216] fix: list not loading smoothly --- .../BaseInvertedFlatList/index.tsx | 1 + src/pages/home/ReportScreen.tsx | 18 +++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 00af11b1ac7c..2ee71fbc6685 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -45,6 +45,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa initialNumToRender = 10, ...rest } = props; + // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more // previous items, until everything is rendered. We also progressively render new data that is added at the start of the diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 92a17f800e51..9744248d4194 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -272,17 +272,21 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const [oldestUnreadReportActionIDValueFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); - const [oldestUnreadReportActionID, setOldestUnreadReportActionID] = useState(oldestUnreadReportActionIDValueFromOnyx); + const [oldestUnreadReportActionIDState, setOldestUnreadReportActionIDState] = useState(oldestUnreadReportActionIDValueFromOnyx); + const oldestUnreadReportActionID = useMemo( + () => (oldestUnreadReportActionIDState === CONST.NOT_FOUND_ID ? undefined : oldestUnreadReportActionIDState), + [oldestUnreadReportActionIDState], + ); // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. useEffect(() => { - if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionID)) { + if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionIDState)) { return; } - setOldestUnreadReportActionID(oldestUnreadReportActionIDValueFromOnyx); + setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); resetOldestUnreadReportActionID(reportID); - }, [oldestUnreadReportActionID, oldestUnreadReportActionIDValueFromOnyx, reportID]); + }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); const { reportActions: unfilteredReportActions, @@ -290,7 +294,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { sortedAllReportActions, hasNewerActions, hasOlderActions, - } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? (oldestUnreadReportActionID === CONST.NOT_FOUND_ID ? undefined : oldestUnreadReportActionID)); + } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? oldestUnreadReportActionID); // wrapping in useMemo because this is array operation and can cause performance issues const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); @@ -494,7 +498,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const handleOpenReport = useCallback((...args) => { // Reset the oldestUnreadReportActionID every time the report is (newly) fetched - setOldestUnreadReportActionID(undefined); + setOldestUnreadReportActionIDState(undefined); openReport(...args); }, []); @@ -809,7 +813,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When we open a report, we have to wait for the oldest unread report action ID to be set and // retrieved from Onyx, in order to get the correct initial report action page from store. - const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionID; + const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionIDState; // Once all the above conditions are met, we can consider the report ready. const isReportReady = !isInitiallyLoadingReport && !isLoadingOldestUnreadReportActionID; From 1f15c70b089e9d755271360b0fcbb8ca665120af Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 15:38:21 +0200 Subject: [PATCH 089/216] fix: onyx value not cleared --- src/pages/home/ReportScreen.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 9744248d4194..cdf96328efa7 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -280,12 +280,16 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. useEffect(() => { - if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionIDState)) { + if (!oldestUnreadReportActionIDValueFromOnyx) { return; } setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); resetOldestUnreadReportActionID(reportID); + + if (oldestUnreadReportActionIDValueFromOnyx !== oldestUnreadReportActionIDState) { + setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); + } }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); const { From 96df2d6b9a38e19e2c0d6d0904b72ca981bf28fd Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 15:38:43 +0200 Subject: [PATCH 090/216] Update ReportScreen.tsx --- src/pages/home/ReportScreen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index cdf96328efa7..4276a532db80 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -284,7 +284,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { return; } - setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); resetOldestUnreadReportActionID(reportID); if (oldestUnreadReportActionIDValueFromOnyx !== oldestUnreadReportActionIDState) { From 91aa0d8fcd317c216b0f9f41e96d17aba28a6a13 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 15:45:00 +0200 Subject: [PATCH 091/216] fix: clear after setting the value --- src/pages/home/ReportScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4276a532db80..67aea3ce900b 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -284,11 +284,11 @@ function ReportScreen({route, navigation}: ReportScreenProps) { return; } - resetOldestUnreadReportActionID(reportID); - if (oldestUnreadReportActionIDValueFromOnyx !== oldestUnreadReportActionIDState) { setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); } + + resetOldestUnreadReportActionID(reportID); }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); const { From d76bcfa1af4753bf62c264d98256a91b76d205a7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 16:59:49 +0200 Subject: [PATCH 092/216] fix: tests --- src/pages/home/ReportScreen.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 67aea3ce900b..f3ff902ac03c 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -280,7 +280,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. useEffect(() => { - if (!oldestUnreadReportActionIDValueFromOnyx) { + if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionIDState)) { return; } @@ -291,6 +291,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { resetOldestUnreadReportActionID(reportID); }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); + console.log({oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID}); + const { reportActions: unfilteredReportActions, linkedAction, From 95921c8c6b2764143f364bbcdb1fb81984af7e07 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 18 Aug 2025 17:11:21 +0200 Subject: [PATCH 093/216] remove console.log --- src/pages/home/ReportScreen.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index f3ff902ac03c..0e60ad0a22a8 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -291,8 +291,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { resetOldestUnreadReportActionID(reportID); }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); - console.log({oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID}); - const { reportActions: unfilteredReportActions, linkedAction, From 6aaad858b2f98e9f1a441210839af8446a724dd0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 19 Aug 2025 10:58:14 +0200 Subject: [PATCH 094/216] dummy commit --- src/pages/home/ReportScreen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index d106a3de3d52..3f12a5292b0b 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -115,7 +115,6 @@ const defaultReportMetadata = { hasLoadingNewerReportActionsError: false, isOptimisticReport: false, }; - const reportDetailScreens = [...Object.values(SCREENS.REPORT_DETAILS), ...Object.values(SCREENS.REPORT_SETTINGS), ...Object.values(SCREENS.PRIVATE_NOTES)]; /** From ac3ace21c062536150ee4a8cba110c1a28a5f520 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 19 Aug 2025 10:58:30 +0200 Subject: [PATCH 095/216] another dummy commit for triggering checks --- src/pages/home/ReportScreen.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 3f12a5292b0b..d106a3de3d52 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -115,6 +115,7 @@ const defaultReportMetadata = { hasLoadingNewerReportActionsError: false, isOptimisticReport: false, }; + const reportDetailScreens = [...Object.values(SCREENS.REPORT_DETAILS), ...Object.values(SCREENS.REPORT_SETTINGS), ...Object.values(SCREENS.PRIVATE_NOTES)]; /** From 6c206dd4b57cb50426f23016124ebf00b6cbf683 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 11:59:17 +0200 Subject: [PATCH 096/216] fix: extract comment linking code to useFlatListScrollKey --- .../BaseInvertedFlatList/index.tsx | 122 +++--------------- src/hooks/useFlatListScrollKey.ts | 106 +++++++++++---- 2 files changed, 98 insertions(+), 130 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 923c2951f92f..d065bd2c7636 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,15 +1,9 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import FlatList from '@components/FlatList'; -import type {ScrollViewProps} from '@components/ScrollView'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; -import usePrevious from '@hooks/usePrevious'; +import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; import CONST from '@src/CONST'; -import type {RenderInfo} from './RenderTaskQueue'; -import RenderTaskQueue from './RenderTaskQueue'; - -const INITIAL_SCROLL_DELAY = 200; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { @@ -42,50 +36,26 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa keyExtractor = defaultKeyExtractor, onInitiallyLoaded, onContentSizeChange, - initialNumToRender = 10, + initialNumToRender = CONST.PAGINATION_SIZE, ...rest } = props; - // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. - // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more - // previous items, until everything is rendered. We also progressively render new data that is added at the start of the - // list to make sure `maintainVisibleContentPosition` works as expected. - const [currentDataId, setCurrentDataId] = useState(() => { - if (initialScrollKey) { - return initialScrollKey; - } - return null; - }); - const [isInitialData, setIsInitialData] = useState(true); - const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - - const {displayedData, negativeScrollIndex} = useMemo(() => { - if (currentDataIndex <= 0) { - return {displayedData: data, negativeScrollIndex: data.length}; - } - - const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE)); - const minInitialIndex = Math.max(0, data.length - initialNumToRender); - return { - displayedData: data.slice(Math.min(itemIndex, minInitialIndex)), - negativeScrollIndex: Math.min(data.length, data.length - itemIndex), - }; - }, [currentDataIndex, data, initialNumToRender, isInitialData]); - const initialNegativeScrollIndex = useRef(negativeScrollIndex); - - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. - const renderQueue = useMemo(() => new RenderTaskQueue(), []); - useEffect(() => { - return () => { - renderQueue.cancel(); - }; - }, [renderQueue]); - - // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded - const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); - const didScroll = useRef(false); + const listRef = useRef<(RNFlatList & HTMLElement) | null>(null); const [didInitialContentRender, setDidInitialContentRender] = useState(false); + const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId, dataIndexDifference} = useFlatListScrollKey({ + listRef, + data, + keyExtractor, + initialScrollKey, + initialNumToRender, + inverted: true, + onStartReached, + shouldEnableAutoScrollToTopThreshold, + didInitialContentRender, + onInitiallyLoaded, + }); + const handleContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { onContentSizeChange?.(contentWidth, contentHeight); @@ -94,54 +64,6 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa [onContentSizeChange], ); - const listRef = useRef(null); - - // When we are initially showing a message on the first page of the whole dataset, - // we don't want to immediately start rendering the list. - // Instead, we wait for the initial data to be displayed, scroll to the item manually and - // then start rendering more items. - useEffect(() => { - if (didScroll.current || !isMessageOnFirstPage.current || !didInitialContentRender) { - return; - } - - listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); - - // We need to wait for a few milliseconds until the scrolling is done, - // before we start rendering additional items in the list. - setTimeout(() => { - didScroll.current = true; - renderQueue.start(); - }, INITIAL_SCROLL_DELAY); - }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue]); - - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; - - renderQueue.setHandler((info: RenderInfo) => { - if (!isLoadingData) { - onStartReached?.(info); - } - - if (isInitialData) { - setIsInitialData(false); - onInitiallyLoaded?.(); - } - - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); - }); - - const handleStartReached = useCallback( - (info: RenderInfo) => { - // Same as above, we want to prevent rendering more items until the linked item on the first page has been scrolled to. - const startRendering = didScroll.current || !isMessageOnFirstPage.current; - renderQueue.add(info, startRendering); - }, - [renderQueue], - ); - const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. @@ -150,16 +72,6 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa [renderItem, dataIndexDifference], ); - const maintainVisibleContentPosition = useMemo(() => { - const enableAutoScrollToTopThreshold = shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData; - - return { - // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? 0 : 0, - autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, - }; - }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - useImperativeHandle(ref, () => { // If we're trying to scroll at the start of the list we need to make sure to // render all items. diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index cf0fc31587e4..648086c9ed59 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -1,21 +1,39 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {FlatList as RNFlatList} from 'react-native'; import RenderTaskQueue from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; +import type {RenderInfo} from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; import type {ScrollViewProps} from '@components/ScrollView'; -import getInitialPaginationSize from '@src/components/InvertedFlatList/BaseInvertedFlatList/getInitialPaginationSize'; +import CONST from '@src/CONST'; import usePrevious from './usePrevious'; +const INITIAL_SCROLL_DELAY = 200; +const AUTOSCROLL_TO_TOP_THRESHOLD = 250; + type FlatListScrollKeyProps = { + listRef: React.RefObject<(RNFlatList & HTMLElement) | null>; data: T[]; keyExtractor: (item: T, index: number) => string; initialScrollKey: string | null | undefined; + initialNumToRender: number; inverted: boolean; onStartReached?: ((info: {distanceFromStart: number}) => void) | null; shouldEnableAutoScrollToTopThreshold?: boolean; + didInitialContentRender: boolean; + onInitiallyLoaded?: () => void; }; -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - -export default function useFlatListScrollKey({data, keyExtractor, initialScrollKey, onStartReached, inverted, shouldEnableAutoScrollToTopThreshold}: FlatListScrollKeyProps) { +export default function useFlatListScrollKey({ + listRef, + data, + keyExtractor, + initialScrollKey, + onStartReached, + inverted, + shouldEnableAutoScrollToTopThreshold, + initialNumToRender, + onInitiallyLoaded, + didInitialContentRender, +}: FlatListScrollKeyProps) { // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more // previous items, until everything is rendered. We also progressively render new data that is added at the start of the @@ -28,10 +46,12 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro }); const [isInitialData, setIsInitialData] = useState(true); const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const displayedData = useMemo(() => { + + const {displayedData, negativeScrollIndex} = useMemo(() => { if (currentDataIndex <= 0) { - return data; + return {displayedData: data, negativeScrollIndex: data.length}; } + // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. // So we will need to return at least the last 2 elements in this case. const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; @@ -42,11 +62,15 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro // making the highlighted item appear at the top of the list. // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place // as the rest of the items are appended. - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); - }, [currentDataIndex, data, inverted, isInitialData]); - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); + const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? offset : CONST.PAGINATION_SIZE)); + const minInitialIndex = Math.max(0, data.length - initialNumToRender); + return { + displayedData: data.slice(Math.min(itemIndex, minInitialIndex)), + negativeScrollIndex: Math.min(data.length, data.length - itemIndex), + }; + }, [currentDataIndex, data, initialNumToRender, inverted, isInitialData]); + const initialNegativeScrollIndex = useRef(negativeScrollIndex); // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(), []); @@ -56,41 +80,73 @@ export default function useFlatListScrollKey({data, keyExtractor, initialScro }; }, [renderQueue]); - renderQueue.setHandler((info) => { + // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded + const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); + const didScroll = useRef(false); + + // When we are initially showing a message on the first page of the whole dataset, + // we don't want to immediately start rendering the list. + // Instead, we wait for the initial data to be displayed, scroll to the item manually and + // then start rendering more items. + useEffect(() => { + if (didScroll.current || !isMessageOnFirstPage.current || !didInitialContentRender) { + return; + } + + listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); + + // We need to wait for a few milliseconds until the scrolling is done, + // before we start rendering additional items in the list. + setTimeout(() => { + didScroll.current = true; + renderQueue.start(); + }, INITIAL_SCROLL_DELAY); + }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue, listRef]); + + const isLoadingData = data.length > displayedData.length; + const wasLoadingData = usePrevious(isLoadingData); + const dataIndexDifference = data.length - displayedData.length; + + renderQueue.setHandler((info: RenderInfo) => { if (!isLoadingData) { onStartReached?.(info); } - setIsInitialData(false); + + if (isInitialData) { + setIsInitialData(false); + onInitiallyLoaded?.(); + } + const firstDisplayedItem = displayedData.at(0); setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); }); const handleStartReached = useCallback( - (info: {distanceFromStart: number}) => { - renderQueue.add(info); + (info: RenderInfo) => { + // Same as above, we want to prevent rendering more items until the linked item on the first page has been scrolled to. + const startRendering = didScroll.current || !isMessageOnFirstPage.current; + renderQueue.add(info, startRendering); }, [renderQueue], ); - const maintainVisibleContentPosition = useMemo(() => { - const config: ScrollViewProps['maintainVisibleContentPosition'] = { + const maintainVisibleContentPosition = useMemo(() => { + const enableAutoScrollToTopThreshold = shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData; + + return { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, + minIndexForVisible: data.length ? 0 : 0, + autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, }; - - if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { - config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; - } - - return config; }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); return { handleStartReached, setCurrentDataId, + dataIndexDifference, displayedData, - maintainVisibleContentPosition, isInitialData, + maintainVisibleContentPosition, }; } From 8167bc5384ad415dcb5054928fa9e5cb65ad6b5c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 12:02:51 +0200 Subject: [PATCH 097/216] fix: improve naming of some variables --- .../BaseInvertedFlatList/index.tsx | 10 +++++----- src/hooks/useFlatListScrollKey.ts | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index d065bd2c7636..03c1bfabec59 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -41,25 +41,25 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa } = props; const listRef = useRef<(RNFlatList & HTMLElement) | null>(null); - const [didInitialContentRender, setDidInitialContentRender] = useState(false); + const [isInitialContentRendered, setIsInitialContentRendered] = useState(false); const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId, dataIndexDifference} = useFlatListScrollKey({ + initialScrollKey, listRef, data, keyExtractor, - initialScrollKey, initialNumToRender, inverted: true, - onStartReached, shouldEnableAutoScrollToTopThreshold, - didInitialContentRender, + isInitialContentRendered, + onStartReached, onInitiallyLoaded, }); const handleContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { onContentSizeChange?.(contentWidth, contentHeight); - setDidInitialContentRender(true); + setIsInitialContentRendered(true); }, [onContentSizeChange], ); diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index 648086c9ed59..c0d70a9b9131 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -16,9 +16,9 @@ type FlatListScrollKeyProps = { initialScrollKey: string | null | undefined; initialNumToRender: number; inverted: boolean; - onStartReached?: ((info: {distanceFromStart: number}) => void) | null; shouldEnableAutoScrollToTopThreshold?: boolean; - didInitialContentRender: boolean; + isInitialContentRendered: boolean; + onStartReached?: ((info: {distanceFromStart: number}) => void) | null; onInitiallyLoaded?: () => void; }; @@ -32,7 +32,7 @@ export default function useFlatListScrollKey({ shouldEnableAutoScrollToTopThreshold, initialNumToRender, onInitiallyLoaded, - didInitialContentRender, + isInitialContentRendered, }: FlatListScrollKeyProps) { // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more @@ -89,7 +89,7 @@ export default function useFlatListScrollKey({ // Instead, we wait for the initial data to be displayed, scroll to the item manually and // then start rendering more items. useEffect(() => { - if (didScroll.current || !isMessageOnFirstPage.current || !didInitialContentRender) { + if (didScroll.current || !isMessageOnFirstPage.current || !isInitialContentRendered) { return; } @@ -101,11 +101,11 @@ export default function useFlatListScrollKey({ didScroll.current = true; renderQueue.start(); }, INITIAL_SCROLL_DELAY); - }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue, listRef]); + }, [currentDataIndex, data.length, displayedData.length, isInitialContentRendered, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue, listRef]); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; + const remainingItemsToDisplay = data.length - displayedData.length; renderQueue.setHandler((info: RenderInfo) => { if (!isLoadingData) { @@ -143,7 +143,7 @@ export default function useFlatListScrollKey({ return { handleStartReached, setCurrentDataId, - dataIndexDifference, + dataIndexDifference: remainingItemsToDisplay, displayedData, isInitialData, maintainVisibleContentPosition, From 1706d2e9881e70a0532a405146624292371100a5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 13:01:37 +0200 Subject: [PATCH 098/216] feat: implement useWithFallbackRef hook --- src/hooks/useWithFallbackRef.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/hooks/useWithFallbackRef.ts diff --git a/src/hooks/useWithFallbackRef.ts b/src/hooks/useWithFallbackRef.ts new file mode 100644 index 000000000000..7bf9fe88e8e7 --- /dev/null +++ b/src/hooks/useWithFallbackRef.ts @@ -0,0 +1,11 @@ +import {useRef} from 'react'; +import type {ForwardedRef} from 'react'; + +function useWithFallbackRef(ref: ForwardedRef) { + const fallbackRef = useRef(null); + const combinedRef = (ref as React.RefObject) ?? fallbackRef; + + return combinedRef; +} + +export default useWithFallbackRef; From 9551c737729ced70328c5e1ab05848b7a1fde232 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 13:03:34 +0200 Subject: [PATCH 099/216] fix: extract more logic to useFlatListScrollKey and useFlatListHandle --- .../FlatListWithScrollKey/index.ios.tsx | 52 ++++++++++++--- .../FlatList/FlatListWithScrollKey/index.tsx | 47 +++++++++++++- src/components/FlatList/types.ts | 6 ++ src/components/FlatList/useFlatListHandle.ts | 61 ++++++++++++++++++ .../BaseInvertedFlatList/index.tsx | 63 +++++-------------- .../MoneyRequestReportActionsList.tsx | 2 +- src/hooks/useFlatListScrollKey.ts | 2 +- 7 files changed, 173 insertions(+), 60 deletions(-) create mode 100644 src/components/FlatList/types.ts create mode 100644 src/components/FlatList/useFlatListHandle.ts diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index 87c70ace8f44..848c5e686e7e 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -2,7 +2,11 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react'; import type {FlatListProps, LayoutChangeEvent, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import {InteractionManager} from 'react-native'; +import type {FlatListInnerRefType} from '@components/FlatList/types'; +import useFlatListHandle from '@components/FlatList/useFlatListHandle'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; +import useWithFallbackRef from '@hooks/useWithFallbackRef'; +import CONST from '@src/CONST'; import FlatList from '..'; type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { @@ -17,28 +21,50 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr * FlatList component that handles initial scroll key. */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, onLayout, onContentSizeChange, ...rest} = props; + const { + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + renderItem, + keyExtractor, + ListHeaderComponent, + onLayout, + onContentSizeChange, + onScrollToIndexFailed, + initialNumToRender = CONST.PAGINATION_SIZE, + ...rest + } = props; + + const listRef = useWithFallbackRef, RNFlatList>(ref); + + const [isInitialContentRendered, setIsInitialContentRendered] = useState(false); + const { displayedData, maintainVisibleContentPosition: maintainVisibleContentPositionProp, handleStartReached, isInitialData, + remainingItemsToDisplay, + setCurrentDataId, } = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, + listRef, + initialNumToRender, + isInitialContentRendered, inverted: false, onStartReached, shouldEnableAutoScrollToTopThreshold, }); - const dataIndexDifference = data.length - displayedData.length; const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); + return renderItem({item, index: index + remainingItemsToDisplay, separators}); }, - [renderItem, dataIndexDifference], + [renderItem, remainingItemsToDisplay], ); const [maintainVisibleContentPosition, setMaintainVisibleContentPosition] = useState(maintainVisibleContentPositionProp); const flatListRef = useRef(null); @@ -58,7 +84,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isInitialData]); - const onLayoutInner = useCallback( + const handleLayout = useCallback( (event: LayoutChangeEvent) => { onLayout?.(event); @@ -67,13 +93,15 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For [onLayout], ); - const onContentSizeChangeInner = useCallback( + const handleContentSizeChange = useCallback( (w: number, h: number) => { onContentSizeChange?.(w, h); + setIsInitialContentRendered(true); if (!initialScrollKey) { return; } + // Since the ListHeaderComponent is only rendered after the data has finished rendering, iOS locks the entire current viewport. // As a result, the viewport does not automatically scroll down to fill the gap at the bottom. // We will check during the initial render (isInitialData === true). If the content height is less than the layout height, @@ -92,6 +120,14 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For [onContentSizeChange, isInitialData, initialScrollKey], ); + useFlatListHandle({ + forwardedRef: ref, + listRef, + remainingItemsToDisplay, + setCurrentDataId, + onScrollToIndexFailed, + }); + return ( { @@ -112,8 +148,8 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For // it will be rendered once the data has finished loading. // This prevents an unnecessary empty space above the highlighted item. ListHeaderComponent={!initialScrollKey || (!!initialScrollKey && !isInitialData) ? ListHeaderComponent : undefined} - onLayout={onLayoutInner} - onContentSizeChange={onContentSizeChangeInner} + onLayout={handleLayout} + onContentSizeChange={handleContentSizeChange} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index 45b1d8b10118..9dc52c5a3000 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -1,7 +1,11 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback} from 'react'; +import React, {forwardRef, useCallback, useState} from 'react'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; +import useFlatListHandle from '@components/FlatList/useFlatListHandle'; +import type {FlatListInnerRefType} from '@components/FlatList/useFlatListHandle'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; +import useWithFallbackRef from '@hooks/useWithFallbackRef'; +import CONST from '@src/CONST'; import FlatList from '..'; type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { @@ -16,17 +20,45 @@ type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScr * FlatList component that handles initial scroll key. */ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor, ListHeaderComponent, ...rest} = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData} = useFlatListScrollKey({ + const { + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + renderItem, + keyExtractor, + ListHeaderComponent, + onContentSizeChange, + onScrollToIndexFailed, + initialNumToRender = CONST.PAGINATION_SIZE, + ...rest + } = props; + + const listRef = useWithFallbackRef, RNFlatList>(ref); + + const [isInitialContentRendered, setIsInitialContentRendered] = useState(false); + + const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, remainingItemsToDisplay, setCurrentDataId} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: false, + listRef, onStartReached, + initialNumToRender, + isInitialContentRendered, shouldEnableAutoScrollToTopThreshold, }); const dataIndexDifference = data.length - displayedData.length; + const handleContentSizeChange = useCallback( + (contentWidth: number, contentHeight: number) => { + onContentSizeChange?.(contentWidth, contentHeight); + setIsInitialContentRendered(true); + }, + [onContentSizeChange], + ); + const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. @@ -35,12 +67,21 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For [renderItem, dataIndexDifference], ); + useFlatListHandle({ + forwardedRef: ref, + listRef, + remainingItemsToDisplay, + setCurrentDataId, + onScrollToIndexFailed, + }); + return ( = RNFlatList & HTMLElement; + +// eslint-disable-next-line import/prefer-default-export +export type {FlatListInnerRefType}; diff --git a/src/components/FlatList/useFlatListHandle.ts b/src/components/FlatList/useFlatListHandle.ts new file mode 100644 index 000000000000..bbb1c659f70a --- /dev/null +++ b/src/components/FlatList/useFlatListHandle.ts @@ -0,0 +1,61 @@ +import {useImperativeHandle} from 'react'; +import type {ForwardedRef} from 'react'; +import type {FlatList as RNFlatList} from 'react-native'; +import type {FlatListInnerRefType} from './types'; + +type UseFlatListHandleProps = { + forwardedRef: ForwardedRef; + listRef: React.RefObject>; + setCurrentDataId: (dataId: string | null) => void; + remainingItemsToDisplay: number; + onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; +}; + +export default function useFlatListHandle({forwardedRef, listRef, setCurrentDataId, remainingItemsToDisplay, onScrollToIndexFailed}: UseFlatListHandleProps) { + useImperativeHandle(forwardedRef, () => { + // If we're trying to scroll at the start of the list we need to make sure to + // render all items. + const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { + if (params.offset === 0) { + setCurrentDataId(null); + } + + requestAnimationFrame(() => { + listRef.current?.scrollToOffset(params); + }); + }; + + const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => { + const actualIndex = params.index - remainingItemsToDisplay; + try { + listRef.current?.scrollToIndex({...params, index: actualIndex}); + } catch (ex) { + // It is possible that scrolling fails since the item we are trying to scroll to + // has not been rendered yet. In this case, we call the onScrollToIndexFailed. + onScrollToIndexFailed?.({ + index: actualIndex, + // These metrics are not implemented. + averageItemLength: 0, + highestMeasuredFrameIndex: 0, + }); + } + }; + + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'scrollToOffset') { + return scrollToOffsetFn; + } + if (prop === 'scrollToIndex') { + return scrollToIndexFn; + } + return listRef.current?.[prop as keyof RNFlatList]; + }, + }, + ) as RNFlatList; + }); +} + +export type {FlatListInnerRefType}; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 03c1bfabec59..11193444c080 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,8 +1,11 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useState} from 'react'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import FlatList from '@components/FlatList'; +import useFlatListHandle from '@components/FlatList/useFlatListHandle'; +import type {FlatListInnerRefType} from '@components/FlatList/useFlatListHandle'; import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; +import useWithFallbackRef from '@hooks/useWithFallbackRef'; import CONST from '@src/CONST'; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 @@ -36,14 +39,16 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa keyExtractor = defaultKeyExtractor, onInitiallyLoaded, onContentSizeChange, + onScrollToIndexFailed, initialNumToRender = CONST.PAGINATION_SIZE, ...rest } = props; - const listRef = useRef<(RNFlatList & HTMLElement) | null>(null); + const listRef = useWithFallbackRef, RNFlatList>(ref); + const [isInitialContentRendered, setIsInitialContentRendered] = useState(false); - const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId, dataIndexDifference} = useFlatListScrollKey({ + const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId, remainingItemsToDisplay} = useFlatListScrollKey({ initialScrollKey, listRef, data, @@ -67,53 +72,17 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); + return renderItem({item, index: index + remainingItemsToDisplay, separators}); }, - [renderItem, dataIndexDifference], + [renderItem, remainingItemsToDisplay], ); - useImperativeHandle(ref, () => { - // If we're trying to scroll at the start of the list we need to make sure to - // render all items. - const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { - if (params.offset === 0) { - setCurrentDataId(null); - } - requestAnimationFrame(() => { - listRef.current?.scrollToOffset(params); - }); - }; - - const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => { - const actualIndex = params.index - dataIndexDifference; - try { - listRef.current?.scrollToIndex({...params, index: actualIndex}); - } catch (ex) { - // It is possible that scrolling fails since the item we are trying to scroll to - // has not been rendered yet. In this case, we call the onScrollToIndexFailed. - props.onScrollToIndexFailed?.({ - index: actualIndex, - // These metrics are not implemented. - averageItemLength: 0, - highestMeasuredFrameIndex: 0, - }); - } - }; - - return new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'scrollToOffset') { - return scrollToOffsetFn; - } - if (prop === 'scrollToIndex') { - return scrollToIndexFn; - } - return listRef.current?.[prop as keyof RNFlatList]; - }, - }, - ) as RNFlatList; + useFlatListHandle({ + forwardedRef: ref, + listRef, + remainingItemsToDisplay, + setCurrentDataId, + onScrollToIndexFailed, }); return ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 7e5efd051705..b4c8af67e9ae 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -11,11 +11,11 @@ import Checkbox from '@components/Checkbox'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/useFlatListScrollKey'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts index c0d70a9b9131..a500dee6f8a6 100644 --- a/src/hooks/useFlatListScrollKey.ts +++ b/src/hooks/useFlatListScrollKey.ts @@ -143,7 +143,7 @@ export default function useFlatListScrollKey({ return { handleStartReached, setCurrentDataId, - dataIndexDifference: remainingItemsToDisplay, + remainingItemsToDisplay, displayedData, isInitialData, maintainVisibleContentPosition, From afac60f1cda30d960bf682e2fe4c1566c7f6a63a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 13:09:16 +0200 Subject: [PATCH 100/216] fix: deprecation warning --- tests/unit/useReportUnreadMessageScrollTrackingTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index fc5df8bbb042..b731915baac8 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -46,7 +46,7 @@ describe('useReportUnreadMessageScrollTracking', () => { // Then expect(result.current.isFloatingMessageCounterVisible).toBe(false); - expect(onTrackScrollingMockFn).not.toBeCalled(); + expect(onTrackScrollingMockFn).not.toHaveBeenCalled(); }); it('returns floatingMessage visibility that was set to a new value', () => { From 2f9a92bb5b0d19c9b11792f3d7c62f5dcfaaca43 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 13:10:13 +0200 Subject: [PATCH 101/216] fix: invalid import --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index b4c8af67e9ae..7e5efd051705 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -11,11 +11,11 @@ import Checkbox from '@components/Checkbox'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/useFlatListScrollKey'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; From 7e9b9b20374bcc1f01dfb963433f29203485ade7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 13:11:32 +0200 Subject: [PATCH 102/216] fix: render content immediately in FlatListWithScrollKey --- .../FlatListWithScrollKey/index.ios.tsx | 5 +---- .../FlatList/FlatListWithScrollKey/index.tsx | 17 ++--------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx index 848c5e686e7e..63adde00902e 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx @@ -38,8 +38,6 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For const listRef = useWithFallbackRef, RNFlatList>(ref); - const [isInitialContentRendered, setIsInitialContentRendered] = useState(false); - const { displayedData, maintainVisibleContentPosition: maintainVisibleContentPositionProp, @@ -53,7 +51,7 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For initialScrollKey, listRef, initialNumToRender, - isInitialContentRendered, + isInitialContentRendered: true, inverted: false, onStartReached, shouldEnableAutoScrollToTopThreshold, @@ -96,7 +94,6 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For const handleContentSizeChange = useCallback( (w: number, h: number) => { onContentSizeChange?.(w, h); - setIsInitialContentRendered(true); if (!initialScrollKey) { return; diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx index 9dc52c5a3000..293436187f40 100644 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/index.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useState} from 'react'; +import React, {forwardRef, useCallback} from 'react'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import useFlatListHandle from '@components/FlatList/useFlatListHandle'; import type {FlatListInnerRefType} from '@components/FlatList/useFlatListHandle'; @@ -28,7 +28,6 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For renderItem, keyExtractor, ListHeaderComponent, - onContentSizeChange, onScrollToIndexFailed, initialNumToRender = CONST.PAGINATION_SIZE, ...rest @@ -36,29 +35,18 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For const listRef = useWithFallbackRef, RNFlatList>(ref); - const [isInitialContentRendered, setIsInitialContentRendered] = useState(false); - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, remainingItemsToDisplay, setCurrentDataId} = useFlatListScrollKey({ data, keyExtractor, initialScrollKey, inverted: false, + isInitialContentRendered: true, listRef, onStartReached, initialNumToRender, - isInitialContentRendered, shouldEnableAutoScrollToTopThreshold, }); const dataIndexDifference = data.length - displayedData.length; - - const handleContentSizeChange = useCallback( - (contentWidth: number, contentHeight: number) => { - onContentSizeChange?.(contentWidth, contentHeight); - setIsInitialContentRendered(true); - }, - [onContentSizeChange], - ); - const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. @@ -81,7 +69,6 @@ function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: For data={displayedData} maintainVisibleContentPosition={maintainVisibleContentPosition} onStartReached={handleStartReached} - onContentSizeChange={handleContentSizeChange} renderItem={handleRenderItem} keyExtractor={keyExtractor} // Since ListHeaderComponent is always prioritized for rendering before the data, From 93c298a9fc255ca5d6ea6b18378cdcbe1ae1893f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 11 Sep 2025 13:12:00 +0200 Subject: [PATCH 103/216] fix: typo --- src/hooks/useWithFallbackRef.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useWithFallbackRef.ts b/src/hooks/useWithFallbackRef.ts index 7bf9fe88e8e7..254606af3087 100644 --- a/src/hooks/useWithFallbackRef.ts +++ b/src/hooks/useWithFallbackRef.ts @@ -1,7 +1,7 @@ import {useRef} from 'react'; import type {ForwardedRef} from 'react'; -function useWithFallbackRef(ref: ForwardedRef) { +function useWithFallbackRef(ref: ForwardedRef) { const fallbackRef = useRef(null); const combinedRef = (ref as React.RefObject) ?? fallbackRef; From eeeae6b71c12013a734e5156bf1221a547cc9a27 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Sep 2025 12:15:42 +0100 Subject: [PATCH 104/216] revert: useFLatListScrollKey PR changes --- .../FlatListWithScrollKey/index.ios.tsx | 158 --------------- .../FlatList/FlatListWithScrollKey/index.tsx | 86 -------- .../BaseInvertedFlatList/index.tsx | 183 +++++++++++++++--- .../MoneyRequestReportActionsList.tsx | 1 - src/hooks/useFlatListScrollKey.ts | 153 --------------- src/pages/home/report/ReportActionsList.tsx | 24 +-- 6 files changed, 156 insertions(+), 449 deletions(-) delete mode 100644 src/components/FlatList/FlatListWithScrollKey/index.ios.tsx delete mode 100644 src/components/FlatList/FlatListWithScrollKey/index.tsx delete mode 100644 src/hooks/useFlatListScrollKey.ts diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx deleted file mode 100644 index 63adde00902e..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react'; -import type {FlatListProps, LayoutChangeEvent, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; -import {InteractionManager} from 'react-native'; -import type {FlatListInnerRefType} from '@components/FlatList/types'; -import useFlatListHandle from '@components/FlatList/useFlatListHandle'; -import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; -import useWithFallbackRef from '@hooks/useWithFallbackRef'; -import CONST from '@src/CONST'; -import FlatList from '..'; - -type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { - data: T[]; - initialScrollKey?: string | null | undefined; - keyExtractor: (item: T, index: number) => string; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; -}; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const { - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor, - ListHeaderComponent, - onLayout, - onContentSizeChange, - onScrollToIndexFailed, - initialNumToRender = CONST.PAGINATION_SIZE, - ...rest - } = props; - - const listRef = useWithFallbackRef, RNFlatList>(ref); - - const { - displayedData, - maintainVisibleContentPosition: maintainVisibleContentPositionProp, - handleStartReached, - isInitialData, - remainingItemsToDisplay, - setCurrentDataId, - } = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - listRef, - initialNumToRender, - isInitialContentRendered: true, - inverted: false, - onStartReached, - shouldEnableAutoScrollToTopThreshold, - }); - - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + remainingItemsToDisplay, separators}); - }, - [renderItem, remainingItemsToDisplay], - ); - const [maintainVisibleContentPosition, setMaintainVisibleContentPosition] = useState(maintainVisibleContentPositionProp); - const flatListRef = useRef(null); - const flatListHeight = useRef(0); - const shouldScrollToEndRef = useRef(false); - - useEffect(() => { - if (isInitialData || initialScrollKey) { - return; - } - // On iOS, after the initial render is complete, if the ListHeaderComponent's height decreases shortly afterward, - // the maintainVisibleContentPosition mechanism on iOS keeps the viewport fixed and does not automatically scroll to fill the empty space above. - // Therefore, once rendering is complete and the highlighted item is kept in the viewport, we disable maintainVisibleContentPosition. - InteractionManager.runAfterInteractions(() => { - setMaintainVisibleContentPosition(undefined); - }); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isInitialData]); - - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - - flatListHeight.current = event.nativeEvent.layout.height; - }, - [onLayout], - ); - - const handleContentSizeChange = useCallback( - (w: number, h: number) => { - onContentSizeChange?.(w, h); - - if (!initialScrollKey) { - return; - } - - // Since the ListHeaderComponent is only rendered after the data has finished rendering, iOS locks the entire current viewport. - // As a result, the viewport does not automatically scroll down to fill the gap at the bottom. - // We will check during the initial render (isInitialData === true). If the content height is less than the layout height, - // it means there is a gap at the bottom. - // Then, once the render is complete (isInitialData === false), we will manually scroll to the bottom. - if (shouldScrollToEndRef.current) { - InteractionManager.runAfterInteractions(() => { - flatListRef.current?.scrollToEnd(); - }); - shouldScrollToEndRef.current = false; - } - if (h < flatListHeight.current && isInitialData) { - shouldScrollToEndRef.current = true; - } - }, - [onContentSizeChange, isInitialData, initialScrollKey], - ); - - useFlatListHandle({ - forwardedRef: ref, - listRef, - remainingItemsToDisplay, - setCurrentDataId, - onScrollToIndexFailed, - }); - - return ( - { - flatListRef.current = el; - if (typeof ref === 'function') { - ref(el); - } else if (ref) { - // eslint-disable-next-line no-param-reassign - ref.current = el; - } - }} - data={displayedData} - maintainVisibleContentPosition={maintainVisibleContentPosition} - onStartReached={handleStartReached} - renderItem={handleRenderItem} - keyExtractor={keyExtractor} - // Since ListHeaderComponent is always prioritized for rendering before the data, - // it will be rendered once the data has finished loading. - // This prevents an unnecessary empty space above the highlighted item. - ListHeaderComponent={!initialScrollKey || (!!initialScrollKey && !isInitialData) ? ListHeaderComponent : undefined} - onLayout={handleLayout} - onContentSizeChange={handleContentSizeChange} - // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} - /> - ); -} - -FlatListWithScrollKey.displayName = 'FlatListWithScrollKey'; - -export default forwardRef(FlatListWithScrollKey); diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx deleted file mode 100644 index 293436187f40..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback} from 'react'; -import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; -import useFlatListHandle from '@components/FlatList/useFlatListHandle'; -import type {FlatListInnerRefType} from '@components/FlatList/useFlatListHandle'; -import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; -import useWithFallbackRef from '@hooks/useWithFallbackRef'; -import CONST from '@src/CONST'; -import FlatList from '..'; - -type FlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex'> & { - data: T[]; - initialScrollKey?: string | null | undefined; - keyExtractor: (item: T, index: number) => string; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; -}; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey(props: FlatListWithScrollKeyProps, ref: ForwardedRef) { - const { - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor, - ListHeaderComponent, - onScrollToIndexFailed, - initialNumToRender = CONST.PAGINATION_SIZE, - ...rest - } = props; - - const listRef = useWithFallbackRef, RNFlatList>(ref); - - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, remainingItemsToDisplay, setCurrentDataId} = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - inverted: false, - isInitialContentRendered: true, - listRef, - onStartReached, - initialNumToRender, - shouldEnableAutoScrollToTopThreshold, - }); - const dataIndexDifference = data.length - displayedData.length; - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [renderItem, dataIndexDifference], - ); - - useFlatListHandle({ - forwardedRef: ref, - listRef, - remainingItemsToDisplay, - setCurrentDataId, - onScrollToIndexFailed, - }); - - return ( - - ); -} - -FlatListWithScrollKey.displayName = 'FlatListWithScrollKey'; - -export default forwardRef(FlatListWithScrollKey); diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 11193444c080..2ee71fbc6685 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,12 +1,13 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useState} from 'react'; -import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; -import useFlatListHandle from '@components/FlatList/useFlatListHandle'; -import type {FlatListInnerRefType} from '@components/FlatList/useFlatListHandle'; -import useFlatListScrollKey from '@hooks/useFlatListScrollKey'; -import useWithFallbackRef from '@hooks/useWithFallbackRef'; +import usePrevious from '@hooks/usePrevious'; import CONST from '@src/CONST'; +import type {RenderInfo} from './RenderTaskQueue'; +import RenderTaskQueue from './RenderTaskQueue'; + +const INITIAL_SCROLL_DELAY = 200; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { @@ -29,6 +30,8 @@ type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' onInitiallyLoaded?: () => void; }; +const AUTOSCROLL_TO_TOP_THRESHOLD = 250; + function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) { const { shouldEnableAutoScrollToTopThreshold = false, @@ -39,50 +42,166 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa keyExtractor = defaultKeyExtractor, onInitiallyLoaded, onContentSizeChange, - onScrollToIndexFailed, - initialNumToRender = CONST.PAGINATION_SIZE, + initialNumToRender = 10, ...rest } = props; - const listRef = useWithFallbackRef, RNFlatList>(ref); + // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. + // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more + // previous items, until everything is rendered. We also progressively render new data that is added at the start of the + // list to make sure `maintainVisibleContentPosition` works as expected. + const [currentDataId, setCurrentDataId] = useState(() => { + if (initialScrollKey) { + return initialScrollKey; + } + return null; + }); + const [isInitialData, setIsInitialData] = useState(true); + const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const [isInitialContentRendered, setIsInitialContentRendered] = useState(false); + const {displayedData, negativeScrollIndex} = useMemo(() => { + if (currentDataIndex <= 0) { + return {displayedData: data, negativeScrollIndex: data.length}; + } - const {displayedData, maintainVisibleContentPosition, handleStartReached, setCurrentDataId, remainingItemsToDisplay} = useFlatListScrollKey({ - initialScrollKey, - listRef, - data, - keyExtractor, - initialNumToRender, - inverted: true, - shouldEnableAutoScrollToTopThreshold, - isInitialContentRendered, - onStartReached, - onInitiallyLoaded, - }); + const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE)); + const minInitialIndex = Math.max(0, data.length - initialNumToRender); + return { + displayedData: data.slice(Math.min(itemIndex, minInitialIndex)), + negativeScrollIndex: Math.min(data.length, data.length - itemIndex), + }; + }, [currentDataIndex, data, initialNumToRender, isInitialData]); + const initialNegativeScrollIndex = useRef(negativeScrollIndex); + + const listRef = useRef<(RNFlatList & HTMLElement) | null>(null); + + // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. + const renderQueue = useMemo(() => new RenderTaskQueue(), []); + useEffect(() => { + return () => { + renderQueue.cancel(); + }; + }, [renderQueue]); + + // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded + const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); + const didScroll = useRef(false); + const [didInitialContentRender, setDidInitialContentRender] = useState(false); const handleContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { onContentSizeChange?.(contentWidth, contentHeight); - setIsInitialContentRendered(true); + setDidInitialContentRender(true); }, [onContentSizeChange], ); + // When we are initially showing a message on the first page of the whole dataset, + // we don't want to immediately start rendering the list. + // Instead, we wait for the initial data to be displayed, scroll to the item manually and + // then start rendering more items. + useEffect(() => { + if (didScroll.current || !isMessageOnFirstPage.current || !didInitialContentRender) { + return; + } + + listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); + + // We need to wait for a few milliseconds until the scrolling is done, + // before we start rendering additional items in the list. + setTimeout(() => { + didScroll.current = true; + renderQueue.start(); + }, INITIAL_SCROLL_DELAY); + }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue]); + + const isLoadingData = data.length > displayedData.length; + const wasLoadingData = usePrevious(isLoadingData); + const dataIndexDifference = data.length - displayedData.length; + + renderQueue.setHandler((info: RenderInfo) => { + if (!isLoadingData) { + onStartReached?.(info); + } + + if (isInitialData) { + setIsInitialData(false); + onInitiallyLoaded?.(); + } + + const firstDisplayedItem = displayedData.at(0); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); + }); + + const handleStartReached = useCallback( + (info: RenderInfo) => { + // Same as above, we want to prevent rendering more items until the linked item on the first page has been scrolled to. + const startRendering = didScroll.current || !isMessageOnFirstPage.current; + renderQueue.add(info, startRendering); + }, + [renderQueue], + ); + const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + remainingItemsToDisplay, separators}); + return renderItem({item, index: index + dataIndexDifference, separators}); }, - [renderItem, remainingItemsToDisplay], + [renderItem, dataIndexDifference], ); - useFlatListHandle({ - forwardedRef: ref, - listRef, - remainingItemsToDisplay, - setCurrentDataId, - onScrollToIndexFailed, + const maintainVisibleContentPosition = useMemo(() => { + const enableAutoScrollToTopThreshold = shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData; + + return { + // This needs to be 1 to avoid using loading views as anchors. + minIndexForVisible: data.length ? 0 : 0, + autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, + }; + }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); + + useImperativeHandle(ref, () => { + // If we're trying to scroll at the start of the list we need to make sure to + // render all items. + const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { + if (params.offset === 0) { + setCurrentDataId(null); + } + requestAnimationFrame(() => { + listRef.current?.scrollToOffset(params); + }); + }; + + const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => { + const actualIndex = params.index - dataIndexDifference; + try { + listRef.current?.scrollToIndex({...params, index: actualIndex}); + } catch (ex) { + // It is possible that scrolling fails since the item we are trying to scroll to + // has not been rendered yet. In this case, we call the onScrollToIndexFailed. + props.onScrollToIndexFailed?.({ + index: actualIndex, + // These metrics are not implemented. + averageItemLength: 0, + highestMeasuredFrameIndex: 0, + }); + } + }; + + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'scrollToOffset') { + return scrollToOffsetFn; + } + if (prop === 'scrollToIndex') { + return scrollToIndexFn; + } + return listRef.current?.[prop as keyof RNFlatList]; + }, + }, + ) as RNFlatList; }); return ( @@ -106,4 +225,6 @@ BaseInvertedFlatList.displayName = 'BaseInvertedFlatList'; export default forwardRef(BaseInvertedFlatList); +export {AUTOSCROLL_TO_TOP_THRESHOLD}; + export type {BaseInvertedFlatListProps}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 7e5efd051705..7d555b651c94 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -410,7 +410,6 @@ function MoneyRequestReportActionsList({ readActionSkippedRef: readActionSkipped, unreadMarkerReportActionIndex, isInverted: false, - hasNewerActions, onTrackScrolling: (event: NativeSyntheticEvent) => { const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; const fullContentHeight = contentSize.height; diff --git a/src/hooks/useFlatListScrollKey.ts b/src/hooks/useFlatListScrollKey.ts deleted file mode 100644 index a500dee6f8a6..000000000000 --- a/src/hooks/useFlatListScrollKey.ts +++ /dev/null @@ -1,153 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {FlatList as RNFlatList} from 'react-native'; -import RenderTaskQueue from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; -import type {RenderInfo} from '@components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue'; -import type {ScrollViewProps} from '@components/ScrollView'; -import CONST from '@src/CONST'; -import usePrevious from './usePrevious'; - -const INITIAL_SCROLL_DELAY = 200; -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - -type FlatListScrollKeyProps = { - listRef: React.RefObject<(RNFlatList & HTMLElement) | null>; - data: T[]; - keyExtractor: (item: T, index: number) => string; - initialScrollKey: string | null | undefined; - initialNumToRender: number; - inverted: boolean; - shouldEnableAutoScrollToTopThreshold?: boolean; - isInitialContentRendered: boolean; - onStartReached?: ((info: {distanceFromStart: number}) => void) | null; - onInitiallyLoaded?: () => void; -}; - -export default function useFlatListScrollKey({ - listRef, - data, - keyExtractor, - initialScrollKey, - onStartReached, - inverted, - shouldEnableAutoScrollToTopThreshold, - initialNumToRender, - onInitiallyLoaded, - isInitialContentRendered, -}: FlatListScrollKeyProps) { - // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. - // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more - // previous items, until everything is rendered. We also progressively render new data that is added at the start of the - // list to make sure `maintainVisibleContentPosition` works as expected. - const [currentDataId, setCurrentDataId] = useState(() => { - if (initialScrollKey) { - return initialScrollKey; - } - return null; - }); - const [isInitialData, setIsInitialData] = useState(true); - const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - - const {displayedData, negativeScrollIndex} = useMemo(() => { - if (currentDataIndex <= 0) { - return {displayedData: data, negativeScrollIndex: data.length}; - } - - // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. - // So we will need to return at least the last 2 elements in this case. - const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; - // We always render the list from the highlighted item to the end of the list because: - // - With an inverted FlatList, items are rendered from bottom to top, - // so the highlighted item stays at the bottom and within the visible viewport. - // - With a non-inverted (base) FlatList, items are rendered from top to bottom, - // making the highlighted item appear at the top of the list. - // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place - // as the rest of the items are appended. - - const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? offset : CONST.PAGINATION_SIZE)); - const minInitialIndex = Math.max(0, data.length - initialNumToRender); - return { - displayedData: data.slice(Math.min(itemIndex, minInitialIndex)), - negativeScrollIndex: Math.min(data.length, data.length - itemIndex), - }; - }, [currentDataIndex, data, initialNumToRender, inverted, isInitialData]); - const initialNegativeScrollIndex = useRef(negativeScrollIndex); - - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. - const renderQueue = useMemo(() => new RenderTaskQueue(), []); - useEffect(() => { - return () => { - renderQueue.cancel(); - }; - }, [renderQueue]); - - // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded - const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); - const didScroll = useRef(false); - - // When we are initially showing a message on the first page of the whole dataset, - // we don't want to immediately start rendering the list. - // Instead, we wait for the initial data to be displayed, scroll to the item manually and - // then start rendering more items. - useEffect(() => { - if (didScroll.current || !isMessageOnFirstPage.current || !isInitialContentRendered) { - return; - } - - listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); - - // We need to wait for a few milliseconds until the scrolling is done, - // before we start rendering additional items in the list. - setTimeout(() => { - didScroll.current = true; - renderQueue.start(); - }, INITIAL_SCROLL_DELAY); - }, [currentDataIndex, data.length, displayedData.length, isInitialContentRendered, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue, listRef]); - - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const remainingItemsToDisplay = data.length - displayedData.length; - - renderQueue.setHandler((info: RenderInfo) => { - if (!isLoadingData) { - onStartReached?.(info); - } - - if (isInitialData) { - setIsInitialData(false); - onInitiallyLoaded?.(); - } - - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); - }); - - const handleStartReached = useCallback( - (info: RenderInfo) => { - // Same as above, we want to prevent rendering more items until the linked item on the first page has been scrolled to. - const startRendering = didScroll.current || !isMessageOnFirstPage.current; - renderQueue.add(info, startRendering); - }, - [renderQueue], - ); - - const maintainVisibleContentPosition = useMemo(() => { - const enableAutoScrollToTopThreshold = shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData; - - return { - // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? 0 : 0, - autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, - }; - }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - - return { - handleStartReached, - setCurrentDataId, - remainingItemsToDisplay, - displayedData, - isInitialData, - maintainVisibleContentPosition, - }; -} - -export {AUTOSCROLL_TO_TOP_THRESHOLD}; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index a2aac0d399ab..01b580c7aaaf 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -6,10 +6,10 @@ import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent} from '@components/ActionSheetAwareScrollView'; import InvertedFlatList from '@components/InvertedFlatList'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; import {PersonalDetailsContext, usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; import useLocalize from '@hooks/useLocalize'; import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import useOnyx from '@hooks/useOnyx'; @@ -186,8 +186,6 @@ function ReportActionsList({ const [draftMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}`, {canBeMissing: true}); const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}`, {canBeMissing: true}); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: false}); - const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(false); const [actionIdToHighlight, setActionIdToHighlight] = useState(''); @@ -355,7 +353,7 @@ function ReportActionsList({ if ( scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionIndex && - reportActionSize.current !== sortedVisibleReportActions.length && + reportActionSize.current > sortedVisibleReportActions.length && hasNewestReportAction ) { setIsFloatingMessageCounterVisible(false); @@ -406,20 +404,8 @@ function ReportActionsList({ } const isLastActionUnread = lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); - if (isUnread(report, transactionThreadReport, isReportArchived) || isLastActionUnread) { - // On desktop, when the notification center is displayed, isVisible will return false. - // 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; - const isScrolledToEnd = scrollingVerticalOffset.current <= CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD; - - if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { - readNewestAction(report.reportID); - if (isFromNotification) { - Navigation.setParams({referrer: undefined}); - } - return; - } + if (!isUnread(report, transactionThreadReport) && !isLastActionUnread) { + return; } // On desktop, when the notification center is displayed, isVisible will return false. @@ -725,7 +711,6 @@ function ReportActionsList({ isReportArchived={isReportArchived} linkedTransactionRouteError={actionLinkedTransactionRouteError} userBillingFundID={userBillingFundID} - isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} /> ); }, @@ -751,7 +736,6 @@ function ReportActionsList({ isUserValidated, personalDetailsList, userBillingFundID, - isTryNewDotNVPDismissed, isReportArchived, ], ); From e5a7ffd9b9659949d5dd00aa11f94f548983aeac Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Sep 2025 12:43:03 +0100 Subject: [PATCH 105/216] fix: rename hook useRefWithFallback for more clarity --- src/hooks/{useWithFallbackRef.ts => useRefWithFallback.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/hooks/{useWithFallbackRef.ts => useRefWithFallback.ts} (72%) diff --git a/src/hooks/useWithFallbackRef.ts b/src/hooks/useRefWithFallback.ts similarity index 72% rename from src/hooks/useWithFallbackRef.ts rename to src/hooks/useRefWithFallback.ts index 254606af3087..706c261823be 100644 --- a/src/hooks/useWithFallbackRef.ts +++ b/src/hooks/useRefWithFallback.ts @@ -1,11 +1,11 @@ import {useRef} from 'react'; import type {ForwardedRef} from 'react'; -function useWithFallbackRef(ref: ForwardedRef) { +function useRefWithFallback(ref: ForwardedRef) { const fallbackRef = useRef(null); const combinedRef = (ref as React.RefObject) ?? fallbackRef; return combinedRef; } -export default useWithFallbackRef; +export default useRefWithFallback; From ec423af8edd009f5fd8b92f62256729c1e95e954 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Sep 2025 12:43:07 +0100 Subject: [PATCH 106/216] feat: make use of useFlatListHandle and useWithFallbackRef --- src/components/FlatList/types.ts | 6 --- .../BaseInvertedFlatList/index.tsx | 54 +++---------------- .../FlatList => hooks}/useFlatListHandle.ts | 3 +- 3 files changed, 10 insertions(+), 53 deletions(-) delete mode 100644 src/components/FlatList/types.ts rename src/{components/FlatList => hooks}/useFlatListHandle.ts (97%) diff --git a/src/components/FlatList/types.ts b/src/components/FlatList/types.ts deleted file mode 100644 index 3d9344e96b93..000000000000 --- a/src/components/FlatList/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {FlatList as RNFlatList} from 'react-native'; - -type FlatListInnerRefType = RNFlatList & HTMLElement; - -// eslint-disable-next-line import/prefer-default-export -export type {FlatListInnerRefType}; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 2ee71fbc6685..e0508c536b54 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,8 +1,11 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; +import useFlatListHandle from '@hooks/useFlatListHandle'; +import type {FlatListInnerRefType} from '@hooks/useFlatListHandle'; import usePrevious from '@hooks/usePrevious'; +import useRefWithFallback from '@hooks/useRefWithFallback'; import CONST from '@src/CONST'; import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; @@ -42,6 +45,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa keyExtractor = defaultKeyExtractor, onInitiallyLoaded, onContentSizeChange, + onScrollToIndexFailed, initialNumToRender = 10, ...rest } = props; @@ -73,7 +77,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa }, [currentDataIndex, data, initialNumToRender, isInitialData]); const initialNegativeScrollIndex = useRef(negativeScrollIndex); - const listRef = useRef<(RNFlatList & HTMLElement) | null>(null); + const listRef = useRefWithFallback, RNFlatList>(ref); // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(), []); @@ -113,7 +117,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa didScroll.current = true; renderQueue.start(); }, INITIAL_SCROLL_DELAY); - }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue]); + }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue, listRef]); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); @@ -160,49 +164,7 @@ function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: Forwa }; }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - useImperativeHandle(ref, () => { - // If we're trying to scroll at the start of the list we need to make sure to - // render all items. - const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { - if (params.offset === 0) { - setCurrentDataId(null); - } - requestAnimationFrame(() => { - listRef.current?.scrollToOffset(params); - }); - }; - - const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => { - const actualIndex = params.index - dataIndexDifference; - try { - listRef.current?.scrollToIndex({...params, index: actualIndex}); - } catch (ex) { - // It is possible that scrolling fails since the item we are trying to scroll to - // has not been rendered yet. In this case, we call the onScrollToIndexFailed. - props.onScrollToIndexFailed?.({ - index: actualIndex, - // These metrics are not implemented. - averageItemLength: 0, - highestMeasuredFrameIndex: 0, - }); - } - }; - - return new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'scrollToOffset') { - return scrollToOffsetFn; - } - if (prop === 'scrollToIndex') { - return scrollToIndexFn; - } - return listRef.current?.[prop as keyof RNFlatList]; - }, - }, - ) as RNFlatList; - }); + useFlatListHandle({forwardedRef: ref, listRef, setCurrentDataId, remainingItemsToDisplay: initialNumToRender, onScrollToIndexFailed}); return ( = RNFlatList & HTMLElement; type UseFlatListHandleProps = { forwardedRef: ForwardedRef; From 22b67e39e7676965ce6b43bb6403309d6b420d24 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Sep 2025 12:51:43 +0100 Subject: [PATCH 107/216] fix: missing property in useReportUnreadMessageScrollTracking call --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 380a04f06f7f..d789b88bc6b3 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -409,6 +409,7 @@ function MoneyRequestReportActionsList({ readActionSkippedRef: readActionSkipped, unreadMarkerReportActionIndex, isInverted: false, + hasNewerActions, onTrackScrolling: (event: NativeSyntheticEvent) => { const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; const fullContentHeight = contentSize.height; From 5d9aa08d8f14b48ff3554090ed3e12c2b46a49d5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Sep 2025 16:23:19 +0100 Subject: [PATCH 108/216] fix: add missing property in ReportActionsList --- src/pages/home/report/ReportActionsList.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 2e566f5c249b..8234e9dcb99a 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -187,6 +187,8 @@ function ReportActionsList({ const [draftMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}`, {canBeMissing: true}); const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}`, {canBeMissing: true}); const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: false}); + const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(false); const [actionIdToHighlight, setActionIdToHighlight] = useState(''); @@ -712,6 +714,7 @@ function ReportActionsList({ isReportArchived={isReportArchived} linkedTransactionRouteError={actionLinkedTransactionRouteError} userBillingFundID={userBillingFundID} + isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} /> ); }, @@ -737,6 +740,7 @@ function ReportActionsList({ isUserValidated, personalDetailsList, userBillingFundID, + isTryNewDotNVPDismissed, isReportArchived, ], ); From b2a5d6ee824562abc59b30324d11a5ca9eebb483 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 2 Oct 2025 16:51:45 +0100 Subject: [PATCH 109/216] refactor: extract oldestUnreadReportActionID logic into separate hook --- src/pages/home/ReportScreen.tsx | 42 ++++++------------ .../report/useOldestUnreadReportActionID.ts | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 28 deletions(-) create mode 100644 src/pages/home/report/useOldestUnreadReportActionID.ts diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4689d98e7a06..f0d7bc7500b2 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -86,7 +86,6 @@ import { navigateToConciergeChat, openReport, readNewestAction, - resetOldestUnreadReportActionID, subscribeToReportLeavingEvents, unsubscribeFromLeavingRoomReportChannel, updateLastVisitTime, @@ -102,6 +101,7 @@ import HeaderView from './HeaderView'; import ReactionListWrapper from './ReactionListWrapper'; import ReportActionsView from './report/ReportActionsView'; import ReportFooter from './report/ReportFooter'; +import useOldestUnreadReportActionID from './report/useOldestUnreadReportActionID'; import type {ActionListContextType, ScrollPosition} from './ReportScreenContext'; import {ActionListContext} from './ReportScreenContext'; @@ -280,25 +280,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - const [oldestUnreadReportActionIDValueFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); - const [oldestUnreadReportActionIDState, setOldestUnreadReportActionIDState] = useState(oldestUnreadReportActionIDValueFromOnyx); - const oldestUnreadReportActionID = useMemo( - () => (oldestUnreadReportActionIDState === CONST.NOT_FOUND_ID ? undefined : oldestUnreadReportActionIDState), - [oldestUnreadReportActionIDState], - ); - - // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. - useEffect(() => { - if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionIDState)) { - return; - } - - if (oldestUnreadReportActionIDValueFromOnyx !== oldestUnreadReportActionIDState) { - setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); - } - - resetOldestUnreadReportActionID(reportID); - }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); + const {oldestUnreadReportActionID, isLoading: isLoadingOldestUnreadReportActionID, reset: resetOldestUnreadReportActionID} = useOldestUnreadReportActionID({reportID}); const { reportActions: unfilteredReportActions, @@ -519,11 +501,14 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [firstRender, shouldShowNotFoundLinkedAction, reportID, isOptimisticDelete, reportMetadata?.isLoadingInitialReportActions, userLeavingStatus, currentReportIDFormRoute], ); - const handleOpenReport = useCallback((...args) => { - // Reset the oldestUnreadReportActionID every time the report is (newly) fetched - setOldestUnreadReportActionIDState(undefined); - openReport(...args); - }, []); + const handleOpenReport = useCallback( + (...args) => { + // Reset the oldestUnreadReportActionID every time the report is (newly) fetched + resetOldestUnreadReportActionID(); + openReport(...args); + }, + [resetOldestUnreadReportActionID], + ); const createOneTransactionThreadReport = useCallback(() => { const currentReportTransaction = getReportTransactions(reportID).filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); @@ -859,11 +844,12 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); // When we open a report, we have to wait for the oldest unread report action ID to be set and - // retrieved from Onyx, in order to get the correct initial report action page from store. - const isLoadingOldestUnreadReportActionID = !isOffline && !oldestUnreadReportActionIDState; + // retrieved from Onyx, in order to get the correct initial report action page from store, + // except for when the user is offline. + const isLoadingOldestUnreadReportActionWhileOnline = !isOffline && isLoadingOldestUnreadReportActionID; // Once all the above conditions are met, we can consider the report ready. - const isReportReady = !isInitiallyLoadingReport && !isLoadingOldestUnreadReportActionID; + const isReportReady = !isInitiallyLoadingReport && !isLoadingOldestUnreadReportActionWhileOnline; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. diff --git a/src/pages/home/report/useOldestUnreadReportActionID.ts b/src/pages/home/report/useOldestUnreadReportActionID.ts new file mode 100644 index 000000000000..876669666e26 --- /dev/null +++ b/src/pages/home/report/useOldestUnreadReportActionID.ts @@ -0,0 +1,43 @@ +import {useCallback, useEffect, useMemo, useState} from 'react'; +import useOnyx from '@hooks/useOnyx'; +import {resetOldestUnreadReportActionID} from '@libs/actions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type UseOldestUnreadReportActionIDProps = { + reportID: string | undefined; +}; + +function useOldestUnreadReportActionID({reportID}: UseOldestUnreadReportActionIDProps) { + const [oldestUnreadReportActionIDValueFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); + const [oldestUnreadReportActionIDState, setOldestUnreadReportActionIDState] = useState(oldestUnreadReportActionIDValueFromOnyx); + const oldestUnreadReportActionID = useMemo( + () => (oldestUnreadReportActionIDState === CONST.NOT_FOUND_ID ? undefined : oldestUnreadReportActionIDState), + [oldestUnreadReportActionIDState], + ); + + // When we open a report, we have to wait for the oldest unread report action ID to be set and + // retrieved from Onyx, in order to get the correct initial report action page from store. + const isLoading = useMemo(() => !oldestUnreadReportActionIDState, [oldestUnreadReportActionIDState]); + + const reset = useCallback(() => { + setOldestUnreadReportActionIDState(undefined); + }, []); + + // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. + useEffect(() => { + if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionIDState)) { + return; + } + + if (oldestUnreadReportActionIDValueFromOnyx !== oldestUnreadReportActionIDState) { + setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); + } + + resetOldestUnreadReportActionID(reportID); + }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); + + return {oldestUnreadReportActionID, isLoading, reset}; +} + +export default useOldestUnreadReportActionID; From 109da8f470d7126704eef3c79f97083e6da8c0ea Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 2 Oct 2025 17:02:58 +0100 Subject: [PATCH 110/216] docs: add comments around the new hook --- src/pages/home/ReportScreen.tsx | 2 ++ .../home/report/useOldestUnreadReportActionID.ts | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index f0d7bc7500b2..d6ae9f10f9a4 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -280,6 +280,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); + // When opening a report we receive the oldestUnreadReportActionID from the backend, + // which is needed to initially open the correct report action page from store. const {oldestUnreadReportActionID, isLoading: isLoadingOldestUnreadReportActionID, reset: resetOldestUnreadReportActionID} = useOldestUnreadReportActionID({reportID}); const { diff --git a/src/pages/home/report/useOldestUnreadReportActionID.ts b/src/pages/home/report/useOldestUnreadReportActionID.ts index 876669666e26..37ff5a43fd6b 100644 --- a/src/pages/home/report/useOldestUnreadReportActionID.ts +++ b/src/pages/home/report/useOldestUnreadReportActionID.ts @@ -8,6 +8,13 @@ type UseOldestUnreadReportActionIDProps = { reportID: string | undefined; }; +/** + * This hook is used to get the oldest unread report action ID for a given report. When a report is opened, + * we first have to fetch the value from Onyx, after which it will get reset, so that it's not used again. + * This hook also provides a reset function and a loading state. + * @param reportID - The ID of the report to get the oldest unread report action ID for. + * @returns The oldest unread report action ID for the given report. + */ function useOldestUnreadReportActionID({reportID}: UseOldestUnreadReportActionIDProps) { const [oldestUnreadReportActionIDValueFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, {canBeMissing: true}); const [oldestUnreadReportActionIDState, setOldestUnreadReportActionIDState] = useState(oldestUnreadReportActionIDValueFromOnyx); @@ -16,8 +23,7 @@ function useOldestUnreadReportActionID({reportID}: UseOldestUnreadReportActionID [oldestUnreadReportActionIDState], ); - // When we open a report, we have to wait for the oldest unread report action ID to be set and - // retrieved from Onyx, in order to get the correct initial report action page from store. + // Whether the oldest unread report action ID is still loading from Onyx. const isLoading = useMemo(() => !oldestUnreadReportActionIDState, [oldestUnreadReportActionIDState]); const reset = useCallback(() => { @@ -37,7 +43,11 @@ function useOldestUnreadReportActionID({reportID}: UseOldestUnreadReportActionID resetOldestUnreadReportActionID(reportID); }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); - return {oldestUnreadReportActionID, isLoading, reset}; + return { + oldestUnreadReportActionID, + isLoading, + reset, + }; } export default useOldestUnreadReportActionID; From 8d24e0e9fc80e19334dce4d8096f4370b523310d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 10 Oct 2025 10:30:49 +0100 Subject: [PATCH 111/216] fix: recursive ref calling loop --- .../InvertedFlatList/BaseInvertedFlatList/index.tsx | 6 ++---- src/hooks/useFlatListHandle.ts | 6 ++++-- src/hooks/useRefWithFallback.ts | 10 ---------- 3 files changed, 6 insertions(+), 16 deletions(-) delete mode 100644 src/hooks/useRefWithFallback.ts diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index ca996a524d9c..4334b588152e 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -5,7 +5,6 @@ import FlatList from '@components/FlatList'; import useFlatListHandle from '@hooks/useFlatListHandle'; import type {FlatListInnerRefType} from '@hooks/useFlatListHandle'; import usePrevious from '@hooks/usePrevious'; -import useRefWithFallback from '@hooks/useRefWithFallback'; import CONST from '@src/CONST'; import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; @@ -78,7 +77,8 @@ function BaseInvertedFlatList({ }, [currentDataIndex, data, initialNumToRender, isInitialData]); const initialNegativeScrollIndex = useRef(negativeScrollIndex); - const listRef = useRefWithFallback, typeof ref>(ref); + const listRef = useRef>(null); + useFlatListHandle({forwardedRef: ref, listRef, setCurrentDataId, remainingItemsToDisplay: initialNumToRender, onScrollToIndexFailed}); // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(), []); @@ -165,8 +165,6 @@ function BaseInvertedFlatList({ }; }, [data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - useFlatListHandle({forwardedRef: ref, listRef, setCurrentDataId, remainingItemsToDisplay: initialNumToRender, onScrollToIndexFailed}); - return ( = RNFlatList & HTMLElement; type UseFlatListHandleProps = { forwardedRef: ForwardedRef | undefined; - listRef: React.RefObject>; + listRef: React.RefObject | null>; setCurrentDataId: (dataId: string | null) => void; remainingItemsToDisplay: number; onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; }; -export default function useFlatListHandle({forwardedRef, listRef, setCurrentDataId, remainingItemsToDisplay, onScrollToIndexFailed}: UseFlatListHandleProps) { +function useFlatListHandle({forwardedRef, listRef, setCurrentDataId, remainingItemsToDisplay, onScrollToIndexFailed}: UseFlatListHandleProps) { useImperativeHandle(forwardedRef, () => { // If we're trying to scroll at the start of the list we need to make sure to // render all items. @@ -59,4 +59,6 @@ export default function useFlatListHandle({forwardedRef, listRef, setCurrentD }); } +export default useFlatListHandle; + export type {FlatListInnerRefType}; diff --git a/src/hooks/useRefWithFallback.ts b/src/hooks/useRefWithFallback.ts deleted file mode 100644 index 198188bb3f6d..000000000000 --- a/src/hooks/useRefWithFallback.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {useRef} from 'react'; - -function useRefWithFallback(ref: InputRefType | undefined) { - const fallbackRef = useRef(null); - const combinedRef = (ref as React.RefObject) ?? fallbackRef; - - return combinedRef; -} - -export default useRefWithFallback; From 48ab5b8c9003b51f9c78629258519f8022580ef0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 16 Oct 2025 20:13:22 +0100 Subject: [PATCH 112/216] refactor: move local constant PAGINATION_SIZE out of CONST --- src/CONST/index.ts | 2 -- .../InvertedFlatList/BaseInvertedFlatList/index.tsx | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4fcc5f89436e..054844bfa613 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5671,8 +5671,6 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', - PAGINATION_SIZE: 15, - /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { HEIGHT: 220, diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 4334b588152e..47550826eb98 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -5,10 +5,10 @@ import FlatList from '@components/FlatList'; import useFlatListHandle from '@hooks/useFlatListHandle'; import type {FlatListInnerRefType} from '@hooks/useFlatListHandle'; import usePrevious from '@hooks/usePrevious'; -import CONST from '@src/CONST'; import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; +const PAGINATION_SIZE = 15; const INITIAL_SCROLL_DELAY = 200; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 @@ -68,7 +68,7 @@ function BaseInvertedFlatList({ return {displayedData: data, negativeScrollIndex: data.length}; } - const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? 0 : CONST.PAGINATION_SIZE)); + const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? 0 : PAGINATION_SIZE)); const minInitialIndex = Math.max(0, data.length - initialNumToRender); return { displayedData: data.slice(Math.min(itemIndex, minInitialIndex)), From 34395f9f9f9ced2aea4c42808e5512cbdbf0a4ff Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 16 Oct 2025 20:27:42 +0100 Subject: [PATCH 113/216] fix: invalid index in `scrollToIndex` --- .../BaseInvertedFlatList/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 47550826eb98..dce95525ca6d 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -77,8 +77,12 @@ function BaseInvertedFlatList({ }, [currentDataIndex, data, initialNumToRender, isInitialData]); const initialNegativeScrollIndex = useRef(negativeScrollIndex); + const isLoadingData = data.length > displayedData.length; + const wasLoadingData = usePrevious(isLoadingData); + const remainingItemsToDisplay = data.length - displayedData.length; + const listRef = useRef>(null); - useFlatListHandle({forwardedRef: ref, listRef, setCurrentDataId, remainingItemsToDisplay: initialNumToRender, onScrollToIndexFailed}); + useFlatListHandle({forwardedRef: ref, listRef, setCurrentDataId, remainingItemsToDisplay, onScrollToIndexFailed}); // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(), []); @@ -120,10 +124,6 @@ function BaseInvertedFlatList({ }, INITIAL_SCROLL_DELAY); }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue, listRef]); - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; - renderQueue.setHandler((info: RenderInfo) => { if (!isLoadingData) { onStartReached?.(info); @@ -150,9 +150,9 @@ function BaseInvertedFlatList({ const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. - return renderItem({item, index: index + dataIndexDifference, separators}); + return renderItem({item, index: index + remainingItemsToDisplay, separators}); }, - [renderItem, dataIndexDifference], + [renderItem, remainingItemsToDisplay], ); const maintainVisibleContentPosition = useMemo(() => { From ffb3c71d8c3926d5f30525beaecf331455ab4952 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Oct 2025 10:43:46 +0100 Subject: [PATCH 114/216] fix: isReportArchived missing in `isUnread` util function call --- src/pages/home/ReportScreen.tsx | 3 ++- src/pages/home/report/ReportActionsList.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index fa42f10475c8..a59bf5c8ed92 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -872,7 +872,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. - const isInitiallyLoadingReport = isUnread(report, transactionThreadReport) && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); + const isInitiallyLoadingReport = + isUnread(report, transactionThreadReport, isReportArchived) && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); // When we open a report, we have to wait for the oldest unread report action ID to be set and // retrieved from Onyx, in order to get the correct initial report action page from store, diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 2d5dd0aabaf9..505ac4e6deb6 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -410,8 +410,8 @@ function ReportActionsList({ }, []); const isReportUnread = useMemo( - () => isUnread(report, transactionThreadReport) || (lastAction && isCurrentActionUnread(report, lastAction)), - [report, transactionThreadReport, lastAction], + () => isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction)), + [report, transactionThreadReport, isReportArchived, lastAction], ); // Mark the report as read when the user initially opens the report and there are unread messages From 4f306329765ab71ee2b0168ae78420cfac59c00a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Oct 2025 10:51:39 +0100 Subject: [PATCH 115/216] refactor: move Onyx.set call --- tests/ui/UnreadIndicatorsTest.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 05fce2ce9cad..f5e956a7bd17 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -219,10 +219,9 @@ async function signInAndGetAppWithUnreadChat(): Promise { Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails), Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report), Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${REPORT_ID}`, CONST.NOT_FOUND_ID), ]); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${REPORT_ID}`, CONST.NOT_FOUND_ID); - // We manually setting the sidebar as loaded since the onLayout event does not fire in tests setSidebarLoaded(); await waitForBatchedUpdatesWithAct(); From 4bed196796daac56801a1b11e57edd7064ad1307 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Oct 2025 10:53:39 +0100 Subject: [PATCH 116/216] fix: UnreadIndicatorsTest --- tests/ui/UnreadIndicatorsTest.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index f5e956a7bd17..690a8ee343ac 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -27,7 +27,6 @@ import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/nativ import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import PusherHelper from '../utils/PusherHelper'; -import {triggerListLayout} from '../utils/ReportTestUtils'; import * as TestHelper from '../utils/TestHelper'; import {navigateToSidebarOption} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -266,7 +265,6 @@ describe('Unread Indicators', () => { return navigateToSidebarOption(0); }) .then(async () => { - triggerListLayout(); act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); // That the report actions are visible along with the created action @@ -412,18 +410,13 @@ describe('Unread Indicators', () => { // Tap the new report option and navigate back to the sidebar again via the back button return navigateToSidebarOption(0); }) - .then(() => - waitFor(() => { - act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); - // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread - const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); - const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); - expect(displayNameTexts).toHaveLength(2); - }), - ) + .then(waitForBatchedUpdates) .then(() => { + act(() => (NativeNavigation as NativeNavigationMock).triggerTransitionEnd()); + // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread const hintText = translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNameTexts = screen.queryAllByLabelText(hintText, {includeHiddenElements: true}); + expect(displayNameTexts).toHaveLength(2); expect((displayNameTexts.at(0)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.normal); expect(screen.getAllByText('B User').at(0)).toBeOnTheScreen(); expect((displayNameTexts.at(1)?.props?.style as TextStyle)?.fontWeight).toBe(FontUtils.fontWeight.bold); @@ -563,7 +556,6 @@ describe('Unread Indicators', () => { signInAndGetAppWithUnreadChat() // Navigate to the chat and simulate leaving a comment from the current user .then(() => navigateToSidebarOption(0)) - .then(() => triggerListLayout()) .then(() => { // Leave a comment as the current user addComment(REPORT_ID, REPORT_ID, 'Current User Comment 1', CONST.DEFAULT_TIME_ZONE); @@ -610,7 +602,6 @@ describe('Unread Indicators', () => { }); await signInAndGetAppWithUnreadChat(); await navigateToSidebarOption(0); - triggerListLayout(); addComment(REPORT_ID, REPORT_ID, 'Comment 1', CONST.DEFAULT_TIME_ZONE); From 83214520e64dcfd65a831fdb312aaeb251da6848 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Oct 2025 11:05:41 +0100 Subject: [PATCH 117/216] fix: disable deprecation warning to pass tests for now --- src/libs/ReportUtils.ts | 1 + tests/ui/PaginationTest.tsx | 2 +- tests/ui/UnreadIndicatorsTest.tsx | 2 +- tests/utils/ReportTestUtils.ts | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e2f6d503844f..366bb8356a5e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import {findFocusedRoute} from '@react-navigation/native'; import {format} from 'date-fns'; import {Str} from 'expensify-common'; diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 3d87b8511e53..2efde7c5417c 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-deprecated */ import * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; import {addSeconds, format, subMinutes} from 'date-fns'; diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 690a8ee343ac..426dbfbdb870 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-deprecated */ import * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import {addSeconds, format, subMinutes, subSeconds} from 'date-fns'; diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts index dff009678580..2bd3c5af44c5 100644 --- a/tests/utils/ReportTestUtils.ts +++ b/tests/utils/ReportTestUtils.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import * as NativeNavigation from '@react-navigation/native'; import {fireEvent, screen, waitFor, within} from '@testing-library/react-native'; import {translateLocal} from '@libs/Localize'; From c2e13cf4349c4b5ee618decb452f8bc960b22c60 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Oct 2025 19:10:33 +0100 Subject: [PATCH 118/216] fix: deprecation warning --- src/libs/actions/Report.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 2c2f2a0ca2d1..91db3bd0666a 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import {findFocusedRoute} from '@react-navigation/native'; import {format as timezoneFormat, toZonedTime} from 'date-fns-tz'; import {Str} from 'expensify-common'; From 67d7982e49777f1e398f8a5b6e3f6e336334bba2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 21 Oct 2025 19:10:41 +0100 Subject: [PATCH 119/216] fix: another new isUnread function usage --- src/pages/home/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 505ac4e6deb6..a708b3d984e7 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -436,7 +436,7 @@ function ReportActionsList({ } const isLastActionUnread = lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); - if (!isUnread(report, transactionThreadReport) && !isLastActionUnread) { + if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { return; } From 11390d3f200e1a8129a197a0803d721e06afe775 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:49:29 +0000 Subject: [PATCH 120/216] docs: add comment about `REPORT_OLDEST_UNREAD_REPORT_ACTION_ID` collection --- src/ONYXKEYS.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index fdd206c0e199..a31026cbfefd 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -708,6 +708,11 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', + /** + * Represents the ID of the oldest unread report action for a given report, + * sent by the backend when opening a report. This is used to initially open + * the correct report action page from store. + */ REPORT_OLDEST_UNREAD_REPORT_ACTION_ID: 'reportOldestUnreadReportActionID_', /** Used for identifying user as admin of a domain */ From 947134d1bc02b2aba102f0074d39e6be3e906ba2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:49:39 +0000 Subject: [PATCH 121/216] fix: eslint deprecation warning --- src/pages/home/report/ReportActionsView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 872ae83389fd..bca28a63c03c 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -286,6 +286,7 @@ function ReportActionsView({ setNavigatingToLinkedMessage(true); // After navigating to the linked reportAction, apply this to correctly set // `autoscrollToTopThreshold` prop when linking to a specific reportAction. + // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { // Using a short delay to ensure the view is updated after interactions timerID = setTimeout(() => setNavigatingToLinkedMessage(false), 10); From f822b79510f8bcd4ec84f3a11090e57eed443aa4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:49:56 +0000 Subject: [PATCH 122/216] fix: remove unnecessary `eslint-disable-next` comments --- src/libs/ReportUtils.ts | 1 - src/libs/actions/Report.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index db4b41e25d5d..ad2b8a1243d1 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-deprecated */ import {findFocusedRoute} from '@react-navigation/native'; import {format} from 'date-fns'; import {Str} from 'expensify-common'; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 4b1ee7b46d5c..c08f81a95951 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-deprecated */ import {findFocusedRoute} from '@react-navigation/native'; import {format as timezoneFormat, toZonedTime} from 'date-fns-tz'; import {Str} from 'expensify-common'; From d1a151faa32709160d064b78ffd9fdd18da1a726 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:50:06 +0000 Subject: [PATCH 123/216] fix: exit early if reportID is nullish --- src/libs/actions/Report.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index c08f81a95951..f9851497b7cb 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -6093,6 +6093,10 @@ function setOptimisticTransactionThread(reportID?: string, parentReportID?: stri * @param reportID - The ID of the report to reset the oldest unread report action ID for. */ function resetOldestUnreadReportActionID(reportID: string | undefined) { + if (!reportID) { + return; + } + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, null); } From 3ea2db97815566d61efb92f455f3cfcd3490da39 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:51:03 +0000 Subject: [PATCH 124/216] fix: remove race condition and use `Promise.finally` --- src/hooks/useLoadReportActions.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index e48c74a8d883..c832d2a1460f 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -7,13 +7,6 @@ import type {Report, ReportAction, Response} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import useNetwork from './useNetwork'; -const GET_REPORT_ACTIONS_MAX_WAITING_TIME = 3000; -function createMaxWaitingTimePromise(maxWaitingTime: number) { - return new Promise((resolve) => { - setTimeout(resolve, maxWaitingTime); - }); -} - type UseLoadReportActionsArguments = { /** The id of the current report */ reportID: string; @@ -112,7 +105,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran getOlderActionsPromises.push(getOlderActions(reportID, currentReportOldest?.reportActionID)); } - Promise.race([createMaxWaitingTimePromise(GET_REPORT_ACTIONS_MAX_WAITING_TIME), Promise.all(getOlderActionsPromises)]).then(() => { + Promise.all(getOlderActionsPromises).finally(() => { isLoadingOlderChats.current = false; }); }, @@ -145,7 +138,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran getNewerActionsPromises.push(getNewerActions(reportID, newestReportAction.reportActionID)); } - Promise.race([createMaxWaitingTimePromise(GET_REPORT_ACTIONS_MAX_WAITING_TIME), Promise.all(getNewerActionsPromises)]).then(() => { + Promise.all(getNewerActionsPromises).finally(() => { isLoadingNewerChats.current = false; }); }, From 22e4a8adac310e4933608d29f5f05dcdd4682b44 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:52:27 +0000 Subject: [PATCH 125/216] fix: improve chat loading finalisation --- src/hooks/useLoadReportActions.ts | 100 +++++++++++++++++++++++++++--- src/libs/actions/Report.ts | 8 +-- 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index c832d2a1460f..586d457a4dff 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useMemo, useRef} from 'react'; +import {useCallback, useEffect, useMemo, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {getNewerActions, getOlderActions} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -40,6 +40,8 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); + const isTransactionThreadReport = !isEmptyObject(transactionThreadReport); + // Track oldest/newest actions per report in a single pass const {currentReportOldest, currentReportNewest, transactionThreadOldest, transactionThreadNewest} = useMemo(() => { let currentReportNewestAction = null; @@ -62,7 +64,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran } // Oldest = last matching action we encounter currentReportOldestAction = action; - } else if (!isEmptyObject(transactionThreadReport) && transactionThreadReport?.reportID === targetReportID) { + } else if (isTransactionThreadReport && transactionThreadReport?.reportID === targetReportID) { // Same logic for transaction thread if (!transactionThreadNewestAction) { transactionThreadNewestAction = action; @@ -77,7 +79,18 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran transactionThreadOldest: transactionThreadOldestAction, transactionThreadNewest: transactionThreadNewestAction, }; - }, [reportActions, allReportActionIDs, reportID, transactionThreadReport]); + }, [allReportActionIDs, reportActions, reportID, transactionThreadReport?.reportID, isTransactionThreadReport]); + + const isReportActionLoaded = useCallback( + (actionID: string | undefined) => { + if (!actionID) { + return false; + } + + return reportActions.some((action) => action.reportActionID === actionID); + }, + [reportActions], + ); /** * Retrieves the next set of reportActions for the chat once we are nearing the end of what we are currently @@ -98,20 +111,45 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran isLoadingOlderChats.current = true; const getOlderActionsPromises: Array> = []; - if (!isEmptyObject(transactionThreadReport)) { - getOlderActionsPromises.push(getOlderActions(reportID, currentReportOldest?.reportActionID)); + getOlderActionsPromises.push(getOlderActions(reportID, currentReportOldest?.reportActionID)); + if (isTransactionThreadReport) { getOlderActionsPromises.push(getOlderActions(transactionThreadReport.reportID, transactionThreadOldest?.reportActionID)); - } else { - getOlderActionsPromises.push(getOlderActions(reportID, currentReportOldest?.reportActionID)); } Promise.all(getOlderActionsPromises).finally(() => { isLoadingOlderChats.current = false; }); }, - [isOffline, oldestReportAction, hasOlderActions, transactionThreadReport, reportID, currentReportOldest?.reportActionID, transactionThreadOldest?.reportActionID], + [ + isOffline, + oldestReportAction, + hasOlderActions, + reportID, + currentReportOldest?.reportActionID, + isTransactionThreadReport, + transactionThreadReport?.reportID, + transactionThreadOldest?.reportActionID, + ], ); + useEffect(() => { + if (!isLoadingOlderChats.current) { + return; + } + + const isOldestReportActionLoaded = isReportActionLoaded(currentReportOldest?.reportActionID); + + if (!isTransactionThreadReport && isOldestReportActionLoaded) { + isLoadingOlderChats.current = false; + return; + } + + const isOldestTransactionThreadReportActionLoaded = isReportActionLoaded(transactionThreadOldest?.reportActionID); + if (isOldestReportActionLoaded && isOldestTransactionThreadReportActionLoaded) { + isLoadingOlderChats.current = false; + } + }, [currentReportOldest?.reportActionID, isReportActionLoaded, isTransactionThreadReport, reportActions, transactionThreadOldest?.reportActionID, transactionThreadReport]); + const loadNewerChats = useCallback( (force = false) => { if ( @@ -131,7 +169,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran isLoadingNewerChats.current = true; const getNewerActionsPromises: Array> = []; - if (!isEmptyObject(transactionThreadReport)) { + if (isTransactionThreadReport) { getNewerActionsPromises.push(getNewerActions(reportID, currentReportNewest?.reportActionID)); getNewerActionsPromises.push(getNewerActions(transactionThreadReport.reportID, transactionThreadNewest?.reportActionID)); } else if (newestReportAction) { @@ -142,9 +180,51 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran isLoadingNewerChats.current = false; }); }, - [isFocused, newestReportAction, hasNewerActions, isOffline, transactionThreadReport, reportID, currentReportNewest?.reportActionID, transactionThreadNewest?.reportActionID], + [ + isFocused, + newestReportAction, + hasNewerActions, + isOffline, + isTransactionThreadReport, + reportID, + currentReportNewest?.reportActionID, + transactionThreadReport?.reportID, + transactionThreadNewest?.reportActionID, + ], ); + useEffect(() => { + if (!isLoadingNewerChats.current) { + return; + } + + if (!isTransactionThreadReport) { + const isNewestReportActionLoaded = isReportActionLoaded(currentReportNewest?.reportActionID); + isLoadingNewerChats.current = false; + const isNewestTransactionThreadReportActionLoaded = isReportActionLoaded(transactionThreadNewest?.reportActionID); + + if (isNewestReportActionLoaded && isNewestTransactionThreadReportActionLoaded) { + isLoadingNewerChats.current = false; + } + + return; + } + + const isNewestReportActionLoaded = isReportActionLoaded(newestReportAction?.reportActionID); + + if (isNewestReportActionLoaded) { + isLoadingNewerChats.current = false; + } + }, [ + currentReportNewest?.reportActionID, + isReportActionLoaded, + isTransactionThreadReport, + newestReportAction?.reportActionID, + reportActions, + transactionThreadNewest?.reportActionID, + transactionThreadReport, + ]); + return { loadOlderChats, loadNewerChats, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index f9851497b7cb..834918217081 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1469,7 +1469,7 @@ function navigateToAndOpenChildReport(childReportID: string | undefined, parentR */ function getOlderActions(reportID: string | undefined, reportActionID: string | undefined) { if (!reportID || !reportActionID) { - return Promise.resolve(); + return; } const optimisticData: OnyxUpdate[] = [ @@ -1509,7 +1509,7 @@ function getOlderActions(reportID: string | undefined, reportActionID: string | reportActionID, }; - return API.paginate( + API.paginate( CONST.API_REQUEST_TYPE.READ, READ_COMMANDS.GET_OLDER_ACTIONS, parameters, @@ -1527,7 +1527,7 @@ function getOlderActions(reportID: string | undefined, reportActionID: string | */ function getNewerActions(reportID: string | undefined, reportActionID: string | undefined) { if (!reportID || !reportActionID) { - return Promise.resolve(); + return; } const optimisticData: OnyxUpdate[] = [ @@ -1567,7 +1567,7 @@ function getNewerActions(reportID: string | undefined, reportActionID: string | reportActionID, }; - return API.paginate( + API.paginate( CONST.API_REQUEST_TYPE.READ, READ_COMMANDS.GET_NEWER_ACTIONS, parameters, From aa76a22b9df939f3de0592dfae91804b241fa570 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:53:20 +0000 Subject: [PATCH 126/216] fix: remove promises --- src/hooks/useLoadReportActions.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 586d457a4dff..6bcb149478e9 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -109,16 +109,11 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran } isLoadingOlderChats.current = true; - const getOlderActionsPromises: Array> = []; - getOlderActionsPromises.push(getOlderActions(reportID, currentReportOldest?.reportActionID)); + getOlderActions(reportID, currentReportOldest?.reportActionID); if (isTransactionThreadReport) { - getOlderActionsPromises.push(getOlderActions(transactionThreadReport.reportID, transactionThreadOldest?.reportActionID)); + getOlderActions(transactionThreadReport.reportID, transactionThreadOldest?.reportActionID); } - - Promise.all(getOlderActionsPromises).finally(() => { - isLoadingOlderChats.current = false; - }); }, [ isOffline, @@ -167,18 +162,13 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran } isLoadingNewerChats.current = true; - const getNewerActionsPromises: Array> = []; if (isTransactionThreadReport) { - getNewerActionsPromises.push(getNewerActions(reportID, currentReportNewest?.reportActionID)); - getNewerActionsPromises.push(getNewerActions(transactionThreadReport.reportID, transactionThreadNewest?.reportActionID)); + getNewerActions(reportID, currentReportNewest?.reportActionID); + getNewerActions(transactionThreadReport.reportID, transactionThreadNewest?.reportActionID); } else if (newestReportAction) { - getNewerActionsPromises.push(getNewerActions(reportID, newestReportAction.reportActionID)); + getNewerActions(reportID, newestReportAction.reportActionID); } - - Promise.all(getNewerActionsPromises).finally(() => { - isLoadingNewerChats.current = false; - }); }, [ isFocused, From 0358bf698ce92c795a1bfec65dc7177d7c90ea86 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 12:53:29 +0000 Subject: [PATCH 127/216] refactor: remove unused type --- src/hooks/useLoadReportActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 6bcb149478e9..6779f98a8478 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -3,7 +3,7 @@ import {useCallback, useEffect, useMemo, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {getNewerActions, getOlderActions} from '@userActions/Report'; import CONST from '@src/CONST'; -import type {Report, ReportAction, Response} from '@src/types/onyx'; +import type {Report, ReportAction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import useNetwork from './useNetwork'; From 9507ca6ec3cf8439f1bac5f9c690af8a19a5c4b2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 31 Oct 2025 14:37:43 +0000 Subject: [PATCH 128/216] fix: remove deprecation rule `eslint-disable` comments --- tests/ui/PaginationTest.tsx | 2 +- tests/ui/UnreadIndicatorsTest.tsx | 2 +- tests/utils/ReportTestUtils.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index d1a8659b1953..e0bb374dc8fe 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-deprecated */ +/* eslint-disable @typescript-eslint/naming-convention */ import * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; import {addSeconds, format, subMinutes} from 'date-fns'; diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 07cd327971ef..9175995bac6b 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-deprecated */ +/* eslint-disable @typescript-eslint/naming-convention */ import * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import {addSeconds, format, subMinutes, subSeconds} from 'date-fns'; diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts index e958f59233ca..04ec800316eb 100644 --- a/tests/utils/ReportTestUtils.ts +++ b/tests/utils/ReportTestUtils.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-deprecated */ import * as NativeNavigation from '@react-navigation/native'; import {fireEvent, screen, waitFor, within} from '@testing-library/react-native'; import CONST from '@src/CONST'; From d1fbb1e1da824d83af1b63ebec46705edce15387 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 4 Nov 2025 18:36:34 +0000 Subject: [PATCH 129/216] fix: stop loading if no report action is loaded --- src/hooks/useLoadReportActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 6779f98a8478..95fa27eebcf4 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -84,7 +84,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran const isReportActionLoaded = useCallback( (actionID: string | undefined) => { if (!actionID) { - return false; + return true; } return reportActions.some((action) => action.reportActionID === actionID); From e3111699fe40a7e6de2bcf6cdf6600f8fb5804aa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 6 Nov 2025 17:31:45 +0000 Subject: [PATCH 130/216] fix: report loading indicator on every open --- src/pages/home/ReportScreen.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index ce0d856ed055..73d111b63cb1 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -897,15 +897,16 @@ function ReportScreen({route, navigation}: ReportScreenProps) { useShowWideRHPVersion(shouldShowWideRHP); + const isReportUnread = isUnread(report, transactionThreadReport, isReportArchived); + // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. - const isInitiallyLoadingReport = - isUnread(report, transactionThreadReport, isReportArchived) && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); + const isInitiallyLoadingReport = isReportUnread && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); // When we open a report, we have to wait for the oldest unread report action ID to be set and // retrieved from Onyx, in order to get the correct initial report action page from store, // except for when the user is offline. - const isLoadingOldestUnreadReportActionWhileOnline = !isOffline && isLoadingOldestUnreadReportActionID; + const isLoadingOldestUnreadReportActionWhileOnline = !isOffline && isReportUnread && isLoadingOldestUnreadReportActionID; // Once all the above conditions are met, we can consider the report ready. const isReportReady = !isInitiallyLoadingReport && !isLoadingOldestUnreadReportActionWhileOnline; From b28acad7513b49ab00c295a9748b89f4554ccefe Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 14:34:09 +0000 Subject: [PATCH 131/216] feat: implement new search for unread report action ID in `PaginationUtils.getContinousChain` --- src/hooks/usePaginatedReportActions.ts | 7 ++++-- src/libs/PaginationUtils.ts | 30 ++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index c6020cf4fcd4..4850b6c3a84e 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -44,8 +44,11 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? if (!sortedAllReportActions?.length) { return {data: [], hasNextPage: false, hasPreviousPage: false}; } - return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID); - }, [reportActionID, reportActionPages, sortedAllReportActions]); + + const isUnreadReportAction = (reportAction: ReportAction) => reportAction.created > (report?.lastReadTime ?? 0); + + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID, isUnreadReportAction); + }, [report?.lastReadTime, reportActionID, reportActionPages, sortedAllReportActions]); const linkedAction = useMemo( () => (reportActionID ? sortedAllReportActions?.find((reportAction) => String(reportAction.reportActionID) === String(reportActionID)) : undefined), diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index 98b9451fe970..06f8bce0361e 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -25,6 +25,12 @@ type ItemWithIndex = { index: number; }; +type ContinuousPageChainResult = { + data: TResource[]; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + /** * Finds the id and index in sortedItems of the first item in the given page that's present in sortedItems. */ @@ -168,10 +174,21 @@ function getContinuousChain( pages: Pages, getID: (item: TResource) => string, id?: string, -): {data: TResource[]; hasNextPage: boolean; hasPreviousPage: boolean} { + idPredicate?: (item: TResource) => boolean, +): ContinuousPageChainResult { + const getResourceById = (item: TResource) => getID(item) === id; + if (pages.length === 0) { - const dataItem = sortedItems.find((item) => getID(item) === id); - return {data: id && !dataItem ? [] : sortedItems, hasNextPage: false, hasPreviousPage: false}; + let data: TResource[] = sortedItems; + if (id) { + const foundDataItems = sortedItems.filter(getResourceById); + + if (foundDataItems) { + data = foundDataItems; + } + } + + return {data, hasNextPage: false, hasPreviousPage: false}; } const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); @@ -184,9 +201,14 @@ function getContinuousChain( lastIndex: 0, }; + let index = -1; if (id) { - const index = sortedItems.findIndex((item) => getID(item) === id); + index = sortedItems.findIndex(getResourceById); + } else if (idPredicate) { + index = sortedItems.findIndex(idPredicate); + } + if (index !== -1 || !idPredicate) { // If we are linking to an action that doesn't exist in Onyx, return an empty array if (index === -1) { return {data: [], hasNextPage: false, hasPreviousPage: false}; From 1e86e77801f5bfe260c6de9f264ce4df924cf3b0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 14:37:28 +0000 Subject: [PATCH 132/216] add comments and rename property --- src/libs/PaginationUtils.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index 06f8bce0361e..f745b677e54e 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -174,7 +174,7 @@ function getContinuousChain( pages: Pages, getID: (item: TResource) => string, id?: string, - idPredicate?: (item: TResource) => boolean, + resourceItemPredicate?: (item: TResource) => boolean, ): ContinuousPageChainResult { const getResourceById = (item: TResource) => getID(item) === id; @@ -202,13 +202,17 @@ function getContinuousChain( }; let index = -1; + + // If an id is provided, find the index of the item with that id if (id) { index = sortedItems.findIndex(getResourceById); - } else if (idPredicate) { - index = sortedItems.findIndex(idPredicate); + } else if (resourceItemPredicate) { + // Otherwise, if a resourceItemPredicate is provided, find the index of the first item that matches the predicate + index = sortedItems.findIndex(resourceItemPredicate); } - if (index !== -1 || !idPredicate) { + // If we found an index or no resource item predicate was used for the search, we want link to the specific page with the item + if (index !== -1 || !resourceItemPredicate) { // If we are linking to an action that doesn't exist in Onyx, return an empty array if (index === -1) { return {data: [], hasNextPage: false, hasPreviousPage: false}; @@ -226,6 +230,7 @@ function getContinuousChain( page = linkedPage; } } else { + // If we didn't find an item with the resourceItemPredicate or no id was provided, we want to link to the first page const pageAtIndex0 = pagesWithIndexes.at(0); if (pageAtIndex0) { page = pageAtIndex0; From a53719e5940eae79b4e4134056bc847d589b1f22 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:00:29 +0000 Subject: [PATCH 133/216] feat: return found item from `getContinousChain` --- src/libs/PaginationUtils.ts | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index f745b677e54e..730b7d30a225 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -25,10 +25,17 @@ type ItemWithIndex = { index: number; }; +type ResourceItemResult = { + index: number; + id: string; + item: TResource; +}; + type ContinuousPageChainResult = { data: TResource[]; hasNextPage: boolean; hasPreviousPage: boolean; + resourceItem?: ResourceItemResult; }; /** @@ -173,14 +180,14 @@ function getContinuousChain( sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string, - id?: string, + resourceId?: string, resourceItemPredicate?: (item: TResource) => boolean, ): ContinuousPageChainResult { - const getResourceById = (item: TResource) => getID(item) === id; + const getResourceById = (item: TResource) => getID(item) === resourceId; if (pages.length === 0) { let data: TResource[] = sortedItems; - if (id) { + if (resourceId) { const foundDataItems = sortedItems.filter(getResourceById); if (foundDataItems) { @@ -202,15 +209,28 @@ function getContinuousChain( }; let index = -1; + let resourceItem: ResourceItemResult | undefined; // If an id is provided, find the index of the item with that id - if (id) { + if (resourceId) { index = sortedItems.findIndex(getResourceById); } else if (resourceItemPredicate) { // Otherwise, if a resourceItemPredicate is provided, find the index of the first item that matches the predicate index = sortedItems.findIndex(resourceItemPredicate); } + // If we found an index, get the item and create a resource item result + if (index) { + const item = sortedItems.at(index); + if (item) { + resourceItem = { + index, + item, + id: getID(item), + }; + } + } + // If we found an index or no resource item predicate was used for the search, we want link to the specific page with the item if (index !== -1 || !resourceItemPredicate) { // If we are linking to an action that doesn't exist in Onyx, return an empty array @@ -241,7 +261,12 @@ function getContinuousChain( return {data: sortedItems, hasNextPage: false, hasPreviousPage: false}; } - return {data: sortedItems.slice(page.firstIndex, page.lastIndex + 1), hasNextPage: page.lastID !== CONST.PAGINATION_END_ID, hasPreviousPage: page.firstID !== CONST.PAGINATION_START_ID}; + return { + data: sortedItems.slice(page.firstIndex, page.lastIndex + 1), + hasNextPage: page.lastID !== CONST.PAGINATION_END_ID, + hasPreviousPage: page.firstID !== CONST.PAGINATION_START_ID, + resourceItem, + }; } export default {mergeAndSortContinuousPages, getContinuousChain}; From a24520a852dd4db00229b0e8453d2cd07c8d1ce9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:23:53 +0000 Subject: [PATCH 134/216] fix: remove `oldestUnreadReportActionID` changes --- src/ONYXKEYS.ts | 8 --- src/libs/Middleware/Pagination.ts | 13 ----- src/libs/actions/Report.ts | 13 ----- src/pages/home/ReportScreen.tsx | 37 +++---------- .../report/useOldestUnreadReportActionID.ts | 53 ------------------- src/types/onyx/Response.ts | 3 -- 6 files changed, 8 insertions(+), 119 deletions(-) delete mode 100644 src/pages/home/report/useOldestUnreadReportActionID.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d19b91d51c94..fb7c1526f162 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -708,13 +708,6 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', - /** - * Represents the ID of the oldest unread report action for a given report, - * sent by the backend when opening a report. This is used to initially open - * the correct report action page from store. - */ - REPORT_OLDEST_UNREAD_REPORT_ACTION_ID: 'reportOldestUnreadReportActionID_', - /** Used for identifying user as admin of a domain */ SHARED_NVP_PRIVATE_ADMIN_ACCESS: 'sharedNVP_private_admin_access_', }, @@ -1101,7 +1094,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED]: OnyxTypes.FundID; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist; [ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; - [ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID]: string; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS]: boolean; }; diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 671a29c1c805..fddd31d0a142 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -1,13 +1,11 @@ import fastMerge from 'expensify-common/dist/fastMerge'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import {WRITE_COMMANDS} from '@libs/API/types'; import type {ApiCommand} from '@libs/API/types'; import Log from '@libs/Log'; import PaginationUtils from '@libs/PaginationUtils'; import CONST from '@src/CONST'; import type {OnyxCollectionKey, OnyxPagesKey, OnyxValues} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Request} from '@src/types/onyx'; import type {PaginatedRequest} from '@src/types/onyx/Request'; import type Middleware from './types'; @@ -135,17 +133,6 @@ const Pagination: Middleware = (requestResponse, request) => { value: mergedPages, }); - if (request.command === WRITE_COMMANDS.OPEN_REPORT) { - // Stores the oldestUnreadReportActionID in Onyx to to allow fetching the correct page initially when a report is loaded. - // This value is reset once the report has finished loading. - const oldestUnreadReportActionID = response.oldestUnreadReportActionID ?? CONST.NOT_FOUND_ID; - response.onyxData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${resourceID}`, - value: oldestUnreadReportActionID, - }); - } - return Promise.resolve(response); }); }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 0fe98ebd6f1a..ffbb45a37087 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -6044,18 +6044,6 @@ function setOptimisticTransactionThread(reportID?: string, parentReportID?: stri }); } -/** - * Resets the oldestUnreadReportActionID stored in Onyx once a report has been loaded, to prevent stale data. - * @param reportID - The ID of the report to reset the oldest unread report action ID for. - */ -function resetOldestUnreadReportActionID(reportID: string | undefined) { - if (!reportID) { - return; - } - - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${reportID}`, null); -} - export type {Video, GuidedSetupData, TaskForParameters, IntroSelected}; export { @@ -6169,5 +6157,4 @@ export { openUnreportedExpense, optimisticReportLastData, setOptimisticTransactionThread, - resetOldestUnreadReportActionID, }; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index e14cb14562b5..247d7992be37 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -109,7 +109,6 @@ import HeaderView from './HeaderView'; import ReactionListWrapper from './ReactionListWrapper'; import ReportActionsView from './report/ReportActionsView'; import ReportFooter from './report/ReportFooter'; -import useOldestUnreadReportActionID from './report/useOldestUnreadReportActionID'; import type {ActionListContextType, ScrollPosition} from './ReportScreenContext'; import {ActionListContext} from './ReportScreenContext'; @@ -295,17 +294,14 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - // When opening a report we receive the oldestUnreadReportActionID from the backend, - // which is needed to initially open the correct report action page from store. - const {oldestUnreadReportActionID, isLoading: isLoadingOldestUnreadReportActionID, reset: resetOldestUnreadReportActionID} = useOldestUnreadReportActionID({reportID}); - const { reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, + oldestUnreadReportActionID, hasNewerActions, hasOlderActions, - } = usePaginatedReportActions(reportID, reportActionIDFromRoute ?? oldestUnreadReportActionID); + } = usePaginatedReportActions(reportID, reportActionIDFromRoute, {shouldLinkToUnreadReportAction: true}); // wrapping in useMemo because this is array operation and can cause performance issues const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); @@ -518,15 +514,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [firstRender, shouldShowNotFoundLinkedAction, reportID, isOptimisticDelete, reportMetadata?.isLoadingInitialReportActions, userLeavingStatus, currentReportIDFormRoute], ); - const handleOpenReport = useCallback( - (...args) => { - // Reset the oldestUnreadReportActionID every time the report is (newly) fetched - resetOldestUnreadReportActionID(); - openReport(...args); - }, - [resetOldestUnreadReportActionID], - ); - const createOneTransactionThreadReport = useCallback(() => { const currentReportTransaction = getReportTransactions(reportID).filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const oneTransactionID = currentReportTransaction.at(0)?.transactionID; @@ -566,14 +553,13 @@ function ReportScreen({route, navigation}: ReportScreenProps) { } } - handleOpenReport(reportIDFromRoute, reportActionIDFromRoute); + openReport(reportIDFromRoute, reportActionIDFromRoute); }, [ reportMetadata.isOptimisticReport, report, isOffline, transactionThreadReportID, transactionThreadReport, - handleOpenReport, reportIDFromRoute, reportActionIDFromRoute, createOneTransactionThreadReport, @@ -691,7 +677,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { if (!shouldUseNarrowLayout || !isFocused || prevIsFocused || !isChatThread(report) || !isHiddenForCurrentUser(report) || isTransactionThreadView) { return; } - handleOpenReport(reportID); + openReport(reportID); // We don't want to run this useEffect every time `report` is changed // Excluding shouldUseNarrowLayout from the dependency list to prevent re-triggering on screen resize events. @@ -904,14 +890,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // which is the only one we will have in cache. const isInitiallyLoadingReport = isReportUnread && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); - // When we open a report, we have to wait for the oldest unread report action ID to be set and - // retrieved from Onyx, in order to get the correct initial report action page from store, - // except for when the user is offline. - const isLoadingOldestUnreadReportActionWhileOnline = !isOffline && isReportUnread && isLoadingOldestUnreadReportActionID; - - // Once all the above conditions are met, we can consider the report ready. - const isReportReady = !isInitiallyLoadingReport && !isLoadingOldestUnreadReportActionWhileOnline; - // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. // We aim to display a loader first, then fetch relevant reportActions, and finally show them. @@ -979,11 +957,12 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - {(!report || !isReportReady || shouldWaitForTransactions) && } - {!!report && isReportReady && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + {(!report || !!isInitiallyLoadingReport || shouldWaitForTransactions) && } + {!!report && !isInitiallyLoadingReport && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( ) : null} - {!!report && isReportReady && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + {!!report && !isInitiallyLoadingReport && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( (oldestUnreadReportActionIDValueFromOnyx); - const oldestUnreadReportActionID = useMemo( - () => (oldestUnreadReportActionIDState === CONST.NOT_FOUND_ID ? undefined : oldestUnreadReportActionIDState), - [oldestUnreadReportActionIDState], - ); - - // Whether the oldest unread report action ID is still loading from Onyx. - const isLoading = useMemo(() => !oldestUnreadReportActionIDState, [oldestUnreadReportActionIDState]); - - const reset = useCallback(() => { - setOldestUnreadReportActionIDState(undefined); - }, []); - - // Set the oldestUnreadReportActionID in state once loaded from Onyx, and clear Onyx state to prevent stale data. - useEffect(() => { - if (!oldestUnreadReportActionIDValueFromOnyx || (oldestUnreadReportActionIDValueFromOnyx && !!oldestUnreadReportActionIDState)) { - return; - } - - if (oldestUnreadReportActionIDValueFromOnyx !== oldestUnreadReportActionIDState) { - setOldestUnreadReportActionIDState(oldestUnreadReportActionIDValueFromOnyx); - } - - resetOldestUnreadReportActionID(reportID); - }, [oldestUnreadReportActionIDState, oldestUnreadReportActionIDValueFromOnyx, reportID]); - - return { - oldestUnreadReportActionID, - isLoading, - reset, - }; -} - -export default useOldestUnreadReportActionID; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 2676dcfe6c4d..037bc1648f14 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -87,9 +87,6 @@ type Response = { /** If there is newer data to load for pagination commands */ hasNewerActions?: boolean; - /** The oldest unread report action ID */ - oldestUnreadReportActionID?: string | null; - /** The email of the original user (returned when in delegate mode) */ requesterEmail?: string; From 8b05d9957a1bca17f33576124d676a834938e028 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:29:40 +0000 Subject: [PATCH 135/216] fix: remove more old changes --- src/CONST/index.ts | 1 - tests/ui/PaginationTest.tsx | 2 -- tests/ui/UnreadIndicatorsTest.tsx | 1 - 3 files changed, 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ecbfacaa1c88..a5e203ae4308 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -949,7 +949,6 @@ const CONST = { USE_EXPENSIFY_URL, EXPENSIFY_URL, EXPENSIFY_MOBILE_URL, - NOT_FOUND_ID: '-1', GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', IMAGE_BASE64_MATCH: 'base64', diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index cf9614b00c39..42d6e98bc193 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -195,7 +195,6 @@ async function signInAndGetApp(): Promise { lastActorAccountID: USER_B_ACCOUNT_ID, type: CONST.REPORT.TYPE.CHAT, }), - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${REPORT_ID}`, '-1'), Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'), }), @@ -227,7 +226,6 @@ async function signInAndGetApp(): Promise { actorAccountID: USER_A_ACCOUNT_ID, }, }), - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${COMMENT_LINKING_REPORT_ID}`, '-1'), ]); // Manually mark the sidebar as loaded since onLayout does not fire in tests. diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 686a190da88e..b83cc3ef1c6c 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -217,7 +217,6 @@ async function signInAndGetAppWithUnreadChat(): Promise { Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails), Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report), Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions), - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_OLDEST_UNREAD_REPORT_ACTION_ID}${REPORT_ID}`, CONST.NOT_FOUND_ID), ]); // We manually setting the sidebar as loaded since the onLayout event does not fire in tests From 70fa0c7c5577a6f551a4c3a53af4fbd834ce893e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:29:45 +0000 Subject: [PATCH 136/216] unnecessary change --- src/pages/home/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index d4a58d768f7b..81a34591fcf2 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -388,7 +388,7 @@ function ReportActionsList({ if ( scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionIndex && - reportActionSize.current > sortedVisibleReportActions.length && + reportActionSize.current !== sortedVisibleReportActions.length && hasNewestReportAction ) { setIsFloatingMessageCounterVisible(false); From d52f129e3aaf48c89e0f4294e6f75395da0c3ad9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:33:56 +0000 Subject: [PATCH 137/216] fix: improve `usePaginatedReportActions` function signature --- src/hooks/usePaginatedReportActions.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 4850b6c3a84e..de7ebd55acba 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -9,10 +9,17 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import useOnyx from './useOnyx'; import useReportIsArchived from './useReportIsArchived'; +type UsePaginatedReportActionsOptions = { + /** Whether to link to the oldest unread report action, if no other report action id is provided. */ + shouldLinkToUnreadReportAction?: boolean; +}; + /** * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions. */ -function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string) { +function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string, options?: UsePaginatedReportActionsOptions) { + const {shouldLinkToUnreadReportAction = false} = options ?? {}; + const nonEmptyStringReportID = getNonEmptyStringOnyxID(reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nonEmptyStringReportID}`, {canBeMissing: true}); const isReportArchived = useReportIsArchived(report?.reportID); @@ -40,27 +47,33 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? data: reportActions, hasNextPage, hasPreviousPage, + resourceItem, } = useMemo(() => { if (!sortedAllReportActions?.length) { return {data: [], hasNextPage: false, hasPreviousPage: false}; } - const isUnreadReportAction = (reportAction: ReportAction) => reportAction.created > (report?.lastReadTime ?? 0); + const isUnreadReportAction = shouldLinkToUnreadReportAction ? (reportAction: ReportAction) => reportAction.created > (report?.lastReadTime ?? 0) : undefined; return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID, isUnreadReportAction); - }, [report?.lastReadTime, reportActionID, reportActionPages, sortedAllReportActions]); + }, [report?.lastReadTime, reportActionID, reportActionPages, shouldLinkToUnreadReportAction, sortedAllReportActions]); const linkedAction = useMemo( () => (reportActionID ? sortedAllReportActions?.find((reportAction) => String(reportAction.reportActionID) === String(reportActionID)) : undefined), [reportActionID, sortedAllReportActions], ); + if (report?.reportID === '2636639376691898') { + console.log({resourceItem}); + } + return { reportActions, linkedAction, sortedAllReportActions, hasOlderActions: hasNextPage, hasNewerActions: hasPreviousPage, + oldestUnreadReportActionID: resourceItem?.id, report, }; } From 80b980cd04c50989d79a342e19d19bad4f06a8a7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:43:09 +0000 Subject: [PATCH 138/216] fix: remove unused `oldestUnreadReportActionID` prop in list componetns --- src/pages/home/ReportScreen.tsx | 2 -- src/pages/home/report/ReportActionsList.tsx | 8 ++------ src/pages/home/report/ReportActionsView.tsx | 5 ----- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 247d7992be37..fe4fcb8899c2 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -298,7 +298,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, - oldestUnreadReportActionID, hasNewerActions, hasOlderActions, } = usePaginatedReportActions(reportID, reportActionIDFromRoute, {shouldLinkToUnreadReportAction: true}); @@ -962,7 +961,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { ; - /** The oldest unread report action ID */ - oldestUnreadReportActionID?: string; - /** Sorted actions prepared for display */ sortedReportActions: OnyxTypes.ReportAction[]; @@ -161,7 +158,6 @@ function ReportActionsList({ loadNewerChats, loadOlderChats, hasNewerActions, - oldestUnreadReportActionID, onLayout, isComposerFullSize, listID, @@ -412,8 +408,8 @@ function ReportActionsList({ }, [report.reportID]); const initialScrollKey = useMemo(() => { - return linkedReportActionID ?? oldestUnreadReportActionID ?? unreadMarkerReportActionID; - }, [linkedReportActionID, oldestUnreadReportActionID, unreadMarkerReportActionID]); + return linkedReportActionID ?? unreadMarkerReportActionID; + }, [linkedReportActionID, unreadMarkerReportActionID]); const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); const handleListInitiallyLoaded = useCallback(() => { diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index bca28a63c03c..f72813b0f0cf 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -51,9 +51,6 @@ type ReportActionsViewProps = { /** The report's parentReportAction */ parentReportAction: OnyxEntry; - /** The oldest unread report action ID */ - oldestUnreadReportActionID?: string; - /** The report metadata loading states */ isLoadingInitialReportActions?: boolean; @@ -82,7 +79,6 @@ function ReportActionsView({ hasNewerActions, hasOlderActions, isReportTransactionThread, - oldestUnreadReportActionID, }: ReportActionsViewProps) { useCopySelectionHelper(); const route = useRoute>(); @@ -327,7 +323,6 @@ function ReportActionsView({ parentReportAction={parentReportAction} parentReportActionForTransactionThread={parentReportActionForTransactionThread} onLayout={recordTimeToMeasureItemLayout} - oldestUnreadReportActionID={oldestUnreadReportActionID} sortedReportActions={reportActions} sortedVisibleReportActions={visibleReportActions} mostRecentIOUReportActionID={mostRecentIOUReportActionID} From a0c52101a9477c856a6cd8186c55688c9ac85aaf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:47:45 +0000 Subject: [PATCH 139/216] fix: simplify PaginationUtils and return `resourceItem` restul --- src/libs/PaginationUtils.ts | 58 ++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index 730b7d30a225..ed99d8b533fd 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -185,43 +185,21 @@ function getContinuousChain( ): ContinuousPageChainResult { const getResourceById = (item: TResource) => getID(item) === resourceId; - if (pages.length === 0) { - let data: TResource[] = sortedItems; - if (resourceId) { - const foundDataItems = sortedItems.filter(getResourceById); - - if (foundDataItems) { - data = foundDataItems; - } - } - - return {data, hasNextPage: false, hasPreviousPage: false}; - } - - const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); - - let page: PageWithIndex = { - ids: [], - firstID: '', - firstIndex: 0, - lastID: '', - lastIndex: 0, - }; - - let index = -1; - let resourceItem: ResourceItemResult | undefined; - // If an id is provided, find the index of the item with that id + let index = -1; if (resourceId) { index = sortedItems.findIndex(getResourceById); } else if (resourceItemPredicate) { // Otherwise, if a resourceItemPredicate is provided, find the index of the first item that matches the predicate index = sortedItems.findIndex(resourceItemPredicate); } + const itemFound = index !== -1; - // If we found an index, get the item and create a resource item result - if (index) { + // If we found an item, return it as the resource item + let resourceItem: ResourceItemResult | undefined; + if (itemFound) { const item = sortedItems.at(index); + console.log({item}); if (item) { resourceItem = { index, @@ -231,11 +209,25 @@ function getContinuousChain( } } + if (pages.length === 0) { + return {data: itemFound ? [] : [], hasNextPage: false, hasPreviousPage: false, resourceItem}; + } + + const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); + + let page: PageWithIndex = { + ids: [], + firstID: '', + firstIndex: 0, + lastID: '', + lastIndex: 0, + }; + // If we found an index or no resource item predicate was used for the search, we want link to the specific page with the item - if (index !== -1 || !resourceItemPredicate) { + if (itemFound || !resourceItemPredicate) { // If we are linking to an action that doesn't exist in Onyx, return an empty array - if (index === -1) { - return {data: [], hasNextPage: false, hasPreviousPage: false}; + if (!itemFound) { + return {data: [], hasNextPage: false, hasPreviousPage: false, resourceItem}; } const linkedPage = pagesWithIndexes.find((pageIndex) => index >= pageIndex.firstIndex && index <= pageIndex.lastIndex); @@ -243,7 +235,7 @@ function getContinuousChain( const item = sortedItems.at(index); // If we are linked to an action in a gap return it by itself if (!linkedPage && item) { - return {data: [item], hasNextPage: false, hasPreviousPage: false}; + return {data: [item], hasNextPage: false, hasPreviousPage: false, resourceItem}; } if (linkedPage) { @@ -258,7 +250,7 @@ function getContinuousChain( } if (!page) { - return {data: sortedItems, hasNextPage: false, hasPreviousPage: false}; + return {data: sortedItems, hasNextPage: false, hasPreviousPage: false, resourceItem}; } return { From c70a88331facf707aaab89cd11b655926ac9a830 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 13 Nov 2025 15:48:13 +0000 Subject: [PATCH 140/216] refactor: use `resourceItem` for `linkedAction` and `oldestUnreadReportActionID` --- src/hooks/usePaginatedReportActions.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index de7ebd55acba..ad51405e1ca9 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -58,13 +58,17 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID, isUnreadReportAction); }, [report?.lastReadTime, reportActionID, reportActionPages, shouldLinkToUnreadReportAction, sortedAllReportActions]); - const linkedAction = useMemo( - () => (reportActionID ? sortedAllReportActions?.find((reportAction) => String(reportAction.reportActionID) === String(reportActionID)) : undefined), - [reportActionID, sortedAllReportActions], - ); + const linkedAction = useMemo(() => resourceItem?.item, [resourceItem]); + + const oldestUnreadReportActionID = useMemo(() => { + if (shouldLinkToUnreadReportAction && resourceItem && reportActionID) { + return resourceItem.id; + } + return undefined; + }, [resourceItem, shouldLinkToUnreadReportAction, reportActionID]); if (report?.reportID === '2636639376691898') { - console.log({resourceItem}); + console.log({resourceItem, linkedAction, oldestUnreadReportActionID}); } return { @@ -73,7 +77,7 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? sortedAllReportActions, hasOlderActions: hasNextPage, hasNewerActions: hasPreviousPage, - oldestUnreadReportActionID: resourceItem?.id, + oldestUnreadReportActionID, report, }; } From 970609f19e97a70593ac4d3662b9e132846e6ea4 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 11:43:12 +0000 Subject: [PATCH 141/216] fix: invalid condition for `oldestUnreadReportActionID` --- src/hooks/usePaginatedReportActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index ad51405e1ca9..cd8167fb63b6 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -61,7 +61,7 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const linkedAction = useMemo(() => resourceItem?.item, [resourceItem]); const oldestUnreadReportActionID = useMemo(() => { - if (shouldLinkToUnreadReportAction && resourceItem && reportActionID) { + if (shouldLinkToUnreadReportAction && resourceItem && !reportActionID) { return resourceItem.id; } return undefined; From 766870f5f92d7e2cef7bbaf70cd27872ea450d19 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 17:03:32 +0000 Subject: [PATCH 142/216] fix: rename `usePaginatedReportActions ` option and fix linked/unread action result --- src/hooks/usePaginatedReportActions.ts | 32 +++++++++++++++----------- src/pages/home/ReportScreen.tsx | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index cd8167fb63b6..7ff4ec74f430 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -11,14 +11,14 @@ import useReportIsArchived from './useReportIsArchived'; type UsePaginatedReportActionsOptions = { /** Whether to link to the oldest unread report action, if no other report action id is provided. */ - shouldLinkToUnreadReportAction?: boolean; + shouldLinkToOldestUnreadReportAction?: boolean; }; /** * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions. */ function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string, options?: UsePaginatedReportActionsOptions) { - const {shouldLinkToUnreadReportAction = false} = options ?? {}; + const {shouldLinkToOldestUnreadReportAction = false} = options ?? {}; const nonEmptyStringReportID = getNonEmptyStringOnyxID(reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nonEmptyStringReportID}`, {canBeMissing: true}); @@ -53,31 +53,35 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return {data: [], hasNextPage: false, hasPreviousPage: false}; } - const isUnreadReportAction = shouldLinkToUnreadReportAction ? (reportAction: ReportAction) => reportAction.created > (report?.lastReadTime ?? 0) : undefined; + const isUnreadReportAction = shouldLinkToOldestUnreadReportAction + ? (reportAction: ReportAction) => { + if (!report?.lastReadTime) { + return false; + } + + return reportAction.created > report.lastReadTime; + } + : undefined; return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID, isUnreadReportAction); - }, [report?.lastReadTime, reportActionID, reportActionPages, shouldLinkToUnreadReportAction, sortedAllReportActions]); + }, [report?.lastReadTime, reportActionID, reportActionPages, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); - const linkedAction = useMemo(() => resourceItem?.item, [resourceItem]); + const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem, reportActionID]); - const oldestUnreadReportActionID = useMemo(() => { - if (shouldLinkToUnreadReportAction && resourceItem && !reportActionID) { - return resourceItem.id; + const oldestUnreadReportAction = useMemo(() => { + if (shouldLinkToOldestUnreadReportAction && resourceItem && !reportActionID) { + return resourceItem.item; } return undefined; - }, [resourceItem, shouldLinkToUnreadReportAction, reportActionID]); - - if (report?.reportID === '2636639376691898') { - console.log({resourceItem, linkedAction, oldestUnreadReportActionID}); - } + }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID]); return { reportActions, linkedAction, + oldestUnreadReportAction, sortedAllReportActions, hasOlderActions: hasNextPage, hasNewerActions: hasPreviousPage, - oldestUnreadReportActionID, report, }; } diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index fe4fcb8899c2..6f11caaffb99 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -300,7 +300,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { sortedAllReportActions, hasNewerActions, hasOlderActions, - } = usePaginatedReportActions(reportID, reportActionIDFromRoute, {shouldLinkToUnreadReportAction: true}); + } = usePaginatedReportActions(reportID, reportActionIDFromRoute, {shouldLinkToOldestUnreadReportAction: true}); // wrapping in useMemo because this is array operation and can cause performance issues const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); From 136e1a7ab34b9623e1cf74ed7631d7b8c609b4f7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 17:03:45 +0000 Subject: [PATCH 143/216] remove log --- src/libs/PaginationUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index ed99d8b533fd..d6f6af13f2f9 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -199,7 +199,6 @@ function getContinuousChain( let resourceItem: ResourceItemResult | undefined; if (itemFound) { const item = sortedItems.at(index); - console.log({item}); if (item) { resourceItem = { index, From 1f5c9fbb1d361d2dd2c201b5b51d6381f3ee60cf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 17:09:10 +0000 Subject: [PATCH 144/216] feat: show skeleton if report actions don't fill page --- src/pages/home/report/ReportActionsList.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 7fb9e2c73bb5..0a48590bce39 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -684,14 +684,14 @@ function ReportActionsList({ * 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 initialNumToRender = useMemo((): number => { 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); if (linkedReportActionID) { return getInitialNumToRender(numToRender); } - return numToRender || undefined; + return numToRender; }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); /** @@ -845,7 +845,10 @@ function ReportActionsList({ ); }, [canShowHeader, retryLoadNewerChatsError]); - const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const isScreenFilled = sortedVisibleReportActions.length >= initialNumToRender; + + const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && !isScreenFilled; + const listFooterComponent = useMemo(() => { if (!shouldShowSkeleton) { From eba6789b549951c18cda6bceda3eda7a5b2632d6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 18:21:13 +0000 Subject: [PATCH 145/216] fix: search oldest unread report action by initial `report.lastReadTime` --- src/hooks/usePaginatedReportActions.ts | 29 +++++++++++++++----------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 7ff4ec74f430..026795c76995 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -1,4 +1,4 @@ -import {useCallback, useMemo} from 'react'; +import {useCallback, useMemo, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import PaginationUtils from '@libs/PaginationUtils'; @@ -43,6 +43,21 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? ); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${nonEmptyStringReportID}`, {canBeMissing: true}); + const initialReportLastReadTime = useRef(report?.lastReadTime); + const isUnreadReportAction = useMemo( + () => + shouldLinkToOldestUnreadReportAction + ? (reportAction: ReportAction) => { + if (!initialReportLastReadTime.current) { + return false; + } + + return reportAction.created > initialReportLastReadTime.current; + } + : undefined, + [shouldLinkToOldestUnreadReportAction], + ); + const { data: reportActions, hasNextPage, @@ -53,18 +68,8 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return {data: [], hasNextPage: false, hasPreviousPage: false}; } - const isUnreadReportAction = shouldLinkToOldestUnreadReportAction - ? (reportAction: ReportAction) => { - if (!report?.lastReadTime) { - return false; - } - - return reportAction.created > report.lastReadTime; - } - : undefined; - return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID, isUnreadReportAction); - }, [report?.lastReadTime, reportActionID, reportActionPages, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); + }, [isUnreadReportAction, reportActionID, reportActionPages, sortedAllReportActions]); const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem, reportActionID]); From 7bf2e3c9c4c821205c581a73e36cfb0b6b0ada79 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 18:21:38 +0000 Subject: [PATCH 146/216] feat: implement loading state for loading initial report actions --- src/pages/home/ReportScreen.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 6f11caaffb99..fd5d62eb4e34 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -298,6 +298,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, + oldestUnreadReportAction, hasNewerActions, hasOlderActions, } = usePaginatedReportActions(reportID, reportActionIDFromRoute, {shouldLinkToOldestUnreadReportAction: true}); @@ -885,9 +886,16 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const isReportUnread = isUnread(report, transactionThreadReport, isReportArchived); + const isLinkedMessagePageLoading = !!reportActionIDFromRoute && !linkedAction; + const isUnreadMessagePageLoading = !reportActionIDFromRoute && isReportUnread && !oldestUnreadReportAction; + + const shouldWaitForOpenReportResult = isLinkedMessagePageLoading || isUnreadMessagePageLoading; + + // console.log({isLinkedMessageLoading: isLinkedMessagePageLoading, isUnreadMessageLoading: isUnreadMessagePageLoading, shouldWaitForOpenReportResult}); + // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. - const isInitiallyLoadingReport = isReportUnread && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); + const isInitiallyLoadingReport = (isReportUnread && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1)) || shouldWaitForOpenReportResult; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. From a5e7fb98924da150a22984d312cfb6822c38dd25 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 18:23:06 +0000 Subject: [PATCH 147/216] fix: search with `resourceItemPredicate` for last index --- src/libs/PaginationUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index d6f6af13f2f9..31f4e5cef2fe 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -191,7 +191,7 @@ function getContinuousChain( index = sortedItems.findIndex(getResourceById); } else if (resourceItemPredicate) { // Otherwise, if a resourceItemPredicate is provided, find the index of the first item that matches the predicate - index = sortedItems.findIndex(resourceItemPredicate); + index = sortedItems.findLastIndex(resourceItemPredicate); } const itemFound = index !== -1; From a0fae0d66830354f8e4c73e00df996092e818d3b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 18:23:48 +0000 Subject: [PATCH 148/216] remove empty line --- src/pages/home/report/ReportActionsList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 0a48590bce39..9577127631da 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -849,7 +849,6 @@ function ReportActionsList({ const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && !isScreenFilled; - const listFooterComponent = useMemo(() => { if (!shouldShowSkeleton) { return; From 2b04286540b6badf194bc68f1c5ed1c8599e5f75 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 18:24:55 +0000 Subject: [PATCH 149/216] remove logs --- src/pages/home/ReportScreen.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index fd5d62eb4e34..cbe21e3437f0 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -891,8 +891,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const shouldWaitForOpenReportResult = isLinkedMessagePageLoading || isUnreadMessagePageLoading; - // console.log({isLinkedMessageLoading: isLinkedMessagePageLoading, isUnreadMessageLoading: isUnreadMessagePageLoading, shouldWaitForOpenReportResult}); - // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. const isInitiallyLoadingReport = (isReportUnread && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1)) || shouldWaitForOpenReportResult; From 7a076d2c1c87efaddbcc6ec9714cfc27897d3c67 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 14 Nov 2025 18:48:12 +0000 Subject: [PATCH 150/216] fix: improve report ready conditions --- src/pages/home/ReportScreen.tsx | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index cbe21e3437f0..3f81c6fe6138 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -885,15 +885,27 @@ function ReportScreen({route, navigation}: ReportScreenProps) { useShowWideRHPVersion(shouldShowWideRHP); const isReportUnread = isUnread(report, transactionThreadReport, isReportArchived); + const isReportUnreadInitially = useRef(isReportUnread); - const isLinkedMessagePageLoading = !!reportActionIDFromRoute && !linkedAction; - const isUnreadMessagePageLoading = !reportActionIDFromRoute && isReportUnread && !oldestUnreadReportAction; + // When we first open a report with a linked report aciton, + // we need to wait for the results from the OpenReport api call, + // if the linked report action is not stored in Onyx. + const isLinkedMessagePageLoadingInitially = !!reportActionIDFromRoute && !linkedAction; - const shouldWaitForOpenReportResult = isLinkedMessagePageLoading || isUnreadMessagePageLoading; + // Same for unread messages, we need to wait for the results from the OpenReport api call, + // if the oldest unread report action is not stored in Onyx. + const isUnreadMessagePageLoadingInitially = !reportActionIDFromRoute && isReportUnreadInitially.current && !oldestUnreadReportAction; + + const shouldWaitForOpenReportResultInitially = isLinkedMessagePageLoadingInitially || isUnreadMessagePageLoadingInitially; + + // console.log({isLinkedMessageLoading: isLinkedMessagePageLoading, isUnreadMessageLoading: isUnreadMessagePageLoading, shouldWaitForOpenReportResult}); // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. - const isInitiallyLoadingReport = (isReportUnread && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1)) || shouldWaitForOpenReportResult; + const isInitiallyLoadingReport = isReportUnread && !!reportMetadata.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); + + // Once all the above conditions are met, we can consider the report ready. + const isReportReady = !isInitiallyLoadingReport && !shouldWaitForOpenReportResultInitially; // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. @@ -962,8 +974,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - {(!report || !!isInitiallyLoadingReport || shouldWaitForTransactions) && } - {!!report && !isInitiallyLoadingReport && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + {(!report || !isReportReady || shouldWaitForTransactions) && } + {!!report && isReportReady && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( ) : null} - {!!report && !isInitiallyLoadingReport && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + {!!report && isReportReady && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( Date: Fri, 14 Nov 2025 19:03:16 +0000 Subject: [PATCH 151/216] refactor: improve screen filling condition --- src/pages/home/report/ReportActionsList.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 9577127631da..58b1f914a944 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -845,9 +845,10 @@ function ReportActionsList({ ); }, [canShowHeader, retryLoadNewerChatsError]); - const isScreenFilled = sortedVisibleReportActions.length >= initialNumToRender; + // const shouldFillScreenDuringIntialListRender = !isListInitiallyLoaded && sortedVisibleReportActions.length < initialNumToRender; + const shouldFillScreenDuringIntialListRender = false; - const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && !isScreenFilled; + const shouldShowSkeleton = (isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED)) || shouldFillScreenDuringIntialListRender; const listFooterComponent = useMemo(() => { if (!shouldShowSkeleton) { From 594570adb01cd0f8c9607d8adf7072f624599f45 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 18:44:52 +0000 Subject: [PATCH 152/216] refactor: add comments and minor refactors --- .../BaseInvertedFlatList/index.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index fa55c2cbe36b..e78bd88cc648 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -60,20 +60,31 @@ function BaseInvertedFlatList({ } return null; }); + const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); + const [isInitialData, setIsInitialData] = useState(true); const [isQueueRendering, setIsQueueRendering] = useState(false); - const currentDataIndex = useMemo(() => (currentDataId === null ? 0 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const {displayedData, negativeScrollIndex} = useMemo(() => { + // If no initially linked item is set, we render the entire dataset. if (currentDataIndex <= 0) { return {displayedData: data, negativeScrollIndex: data.length}; } + // On first render, we only render the items up to the initially linked item. + // This allows `maintainVisibleContentPosition` to render the initially linked item at the bottom of the list. const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? 0 : PAGINATION_SIZE)); + + // On the first render, we need to ensure that we render at least the initial number of items. + // If the initially linked item is closer to the end of the list, we need to render more items and + // therefore the initially linked element will not be rendered right at the bottom of the list. const minInitialIndex = Math.max(0, data.length - initialNumToRender); + const firstItemIndex = Math.min(itemIndex, minInitialIndex); + return { - displayedData: data.slice(Math.min(itemIndex, minInitialIndex)), + // The dataset up to the initially linked item. + displayedData: data.slice(firstItemIndex), + // This is needed to allow scrolling to the initially linked item, when it's on the first page of the dataset. negativeScrollIndex: Math.min(data.length, data.length - itemIndex), }; }, [currentDataIndex, data, initialNumToRender, isInitialData]); From f7ef85d0ae4c488929dd5cae8155898f4bc0c956 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 18:45:06 +0000 Subject: [PATCH 153/216] fix: remove redundant conditional --- src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index e78bd88cc648..1520fff98cc5 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -177,7 +177,7 @@ function BaseInvertedFlatList({ return { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? 0 : 0, + minIndexForVisible: 0, autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, }; }, [initialScrollKey, isInitialData, isQueueRendering, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData, data.length]); From 7f1cd9b78531ed8ba5b90435d766ffdb980755ac Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 18:45:29 +0000 Subject: [PATCH 154/216] fix: add missing `isInitiallyLoaded` dependency --- src/pages/home/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 58b1f914a944..5f45f2a36509 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -504,7 +504,7 @@ function ReportActionsList({ // We will mark the report as read in the above case which marks the LHN report item as read while showing the new message // marker for the chat messages received while the user wasn't focused on the report or on another browser tab for web. // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isFocused, isVisible]); + }, [isFocused, isVisible, isListInitiallyLoaded]); const prevHandleReportChangeMarkAsRead = useRef<() => void>(null); const prevHandleAppVisibilityMarkAsRead = useRef<() => void>(null); From 8f5f094d435364afbf82f16ccdc0ca55a01a0faa Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 21:19:39 +0000 Subject: [PATCH 155/216] refactor: move variable --- .../BaseInvertedFlatList/index.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 1520fff98cc5..77ea0c0c3429 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -97,6 +97,15 @@ function BaseInvertedFlatList({ const listRef = useRef>(null); useFlatListHandle({forwardedRef: ref, listRef, setCurrentDataId, remainingItemsToDisplay, onScrollToIndexFailed}); + const [didInitialContentRender, setDidInitialContentRender] = useState(false); + const handleContentSizeChange = useCallback( + (contentWidth: number, contentHeight: number) => { + onContentSizeChange?.(contentWidth, contentHeight); + setDidInitialContentRender(true); + }, + [onContentSizeChange], + ); + // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); useEffect(() => { @@ -108,15 +117,6 @@ function BaseInvertedFlatList({ // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); const didScroll = useRef(false); - const [didInitialContentRender, setDidInitialContentRender] = useState(false); - - const handleContentSizeChange = useCallback( - (contentWidth: number, contentHeight: number) => { - onContentSizeChange?.(contentWidth, contentHeight); - setDidInitialContentRender(true); - }, - [onContentSizeChange], - ); // When we are initially showing a message on the first page of the whole dataset, // we don't want to immediately start rendering the list. From ffbb946c9e1f787148c9ba861faa9125c2d0acc9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 21:39:38 +0000 Subject: [PATCH 156/216] fix: start render task queue with delay --- .../BaseInvertedFlatList/RenderTaskQueue.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx index fafdfb608220..5b00bebb75c0 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/RenderTaskQueue.tsx @@ -23,7 +23,7 @@ class RenderTaskQueue { this.renderInfos.push(info); if (!this.isRendering && startRendering) { - this.render(); + this.renderWithDelay(); } } @@ -31,7 +31,7 @@ class RenderTaskQueue { if (this.isRendering) { return; } - this.render(); + this.renderWithDelay(); } setHandler(handler: (info: RenderInfo) => void) { @@ -47,6 +47,12 @@ class RenderTaskQueue { this.onIsRenderingChange?.(false); } + private renderWithDelay() { + this.timeout = setTimeout(() => { + this.render(); + }, RENDER_DELAY); + } + private render() { const info = this.renderInfos.shift(); if (!info) { @@ -59,9 +65,7 @@ class RenderTaskQueue { this.handler?.(info); - this.timeout = setTimeout(() => { - this.render(); - }, RENDER_DELAY); + this.renderWithDelay(); } } From ec7c41b3896eb89dc0337e9e0ef93f329e6abc7e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 21:39:53 +0000 Subject: [PATCH 157/216] fix: remove null for `currentDataID` instead of empty string --- src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 77ea0c0c3429..61332bbdcc88 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -148,7 +148,7 @@ function BaseInvertedFlatList({ } const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : ''); + setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, currentDataIndex) : null); }); const handleStartReached = useCallback( From 9073cb08a4c0aca8140bda77e143d99ed77d8902 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 21:40:17 +0000 Subject: [PATCH 158/216] refactor: remove unused `useEffect` deps --- src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 61332bbdcc88..b39caa604ade 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -135,7 +135,7 @@ function BaseInvertedFlatList({ didScroll.current = true; renderQueue.start(); }, INITIAL_SCROLL_DELAY); - }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isInitialData, isMessageOnFirstPage, onInitiallyLoaded, renderQueue, listRef]); + }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isMessageOnFirstPage, renderQueue, listRef]); renderQueue.setHandler((info: RenderInfo) => { if (!isLoadingData) { From 7f3b5b0a69fedf0f6299dd004ef6f6fdc84bfaf1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 21:41:21 +0000 Subject: [PATCH 159/216] fix: dependency array --- src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index b39caa604ade..ad3b1c5261fa 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -180,7 +180,7 @@ function BaseInvertedFlatList({ minIndexForVisible: 0, autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, }; - }, [initialScrollKey, isInitialData, isQueueRendering, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData, data.length]); + }, [initialScrollKey, isInitialData, isLoadingData, isQueueRendering, shouldEnableAutoScrollToTopThreshold, wasLoadingData]); return ( Date: Mon, 17 Nov 2025 21:47:32 +0000 Subject: [PATCH 160/216] fix: maintainVisibleContentPosition --- .../InvertedFlatList/BaseInvertedFlatList/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index ad3b1c5261fa..70e66e696d51 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -177,10 +177,10 @@ function BaseInvertedFlatList({ return { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: 0, + minIndexForVisible: displayedData.length ? Math.min(1, displayedData.length - 1) : 0, autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, }; - }, [initialScrollKey, isInitialData, isLoadingData, isQueueRendering, shouldEnableAutoScrollToTopThreshold, wasLoadingData]); + }, [displayedData.length, initialScrollKey, isInitialData, isLoadingData, isQueueRendering, shouldEnableAutoScrollToTopThreshold, wasLoadingData]); return ( Date: Mon, 17 Nov 2025 22:13:06 +0000 Subject: [PATCH 161/216] fix: Expensicons deprecated --- src/pages/home/ReportScreen.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 754dbc9caa77..4551e629e050 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -12,7 +12,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Banner from '@components/Banner'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; -import * as Expensicons from '@components/Icon/Expensicons'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; @@ -27,6 +26,7 @@ import useCurrentReportID from '@hooks/useCurrentReportID'; import useDeepCompareRef from '@hooks/useDeepCompareRef'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useNewTransactions from '@hooks/useNewTransactions'; @@ -151,6 +151,9 @@ function isEmpty(report: OnyxEntry): boolean { function ReportScreen({route, navigation}: ReportScreenProps) { const styles = useThemeStyles(); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb'] as const); + const {translate} = useLocalize(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); @@ -953,7 +956,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { onClose={dismissBanner} onButtonPress={chatWithAccountManager} shouldShowCloseButton - icon={Expensicons.Lightbulb} + icon={expensifyIcons.Lightbulb} shouldShowIcon shouldShowButton /> From c70056c66d644532d08b603230ea4327d4bb0d66 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 22:13:14 +0000 Subject: [PATCH 162/216] fix: spell check --- src/pages/home/ReportScreen.tsx | 2 +- src/pages/home/report/ReportActionsList.tsx | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4551e629e050..72b0bd0c0ff9 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -891,7 +891,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const isReportUnread = isUnread(report, transactionThreadReport, isReportArchived); const isReportUnreadInitially = useRef(isReportUnread); - // When we first open a report with a linked report aciton, + // When we first open a report with a linked report action, // we need to wait for the results from the OpenReport api call, // if the linked report action is not stored in Onyx. const isLinkedMessagePageLoadingInitially = !!reportActionIDFromRoute && !linkedAction; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 5f45f2a36509..f928f820390f 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -845,10 +845,7 @@ function ReportActionsList({ ); }, [canShowHeader, retryLoadNewerChatsError]); - // const shouldFillScreenDuringIntialListRender = !isListInitiallyLoaded && sortedVisibleReportActions.length < initialNumToRender; - const shouldFillScreenDuringIntialListRender = false; - - const shouldShowSkeleton = (isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED)) || shouldFillScreenDuringIntialListRender; + const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); const listFooterComponent = useMemo(() => { if (!shouldShowSkeleton) { From e7858f5222bba2f219ef449ccaca6755087be6bf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 22:16:32 +0000 Subject: [PATCH 163/216] fix: RenderTaskQueue tests --- tests/unit/RenderTaskQueueTest.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/RenderTaskQueueTest.ts b/tests/unit/RenderTaskQueueTest.ts index 243cf24ba989..7560861839d2 100644 --- a/tests/unit/RenderTaskQueueTest.ts +++ b/tests/unit/RenderTaskQueueTest.ts @@ -21,6 +21,8 @@ describe('RenderTaskQueue', () => { // When a task is added and allowed to complete queue.add({distanceFromStart: 100}); + jest.advanceTimersByTime(500); + // Then the callback is invoked with true when rendering starts expect(mockOnIsRenderingChange).toHaveBeenCalledWith(true); jest.advanceTimersByTime(500); @@ -37,6 +39,9 @@ describe('RenderTaskQueue', () => { // When a task is added but canceled before completion queue.add({distanceFromStart: 100}); + + jest.advanceTimersByTime(500); + queue.cancel(); // Then the callback is invoked with true when rendering starts From a091b4fa01e0d10bad9db0a33e2f8da80d38e0f7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 22:24:51 +0000 Subject: [PATCH 164/216] fix: reduce Pagina`getContinuousChain` to one resource ID param --- src/hooks/usePaginatedReportActions.ts | 2 +- src/libs/PaginationUtils.ts | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 026795c76995..ae3052075ead 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -68,7 +68,7 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return {data: [], hasNextPage: false, hasPreviousPage: false}; } - return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID, isUnreadReportAction); + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID ?? isUnreadReportAction); }, [isUnreadReportAction, reportActionID, reportActionPages, sortedAllReportActions]); const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem, reportActionID]); diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index 31f4e5cef2fe..6768fda076e8 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -180,18 +180,15 @@ function getContinuousChain( sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string, - resourceId?: string, - resourceItemPredicate?: (item: TResource) => boolean, + resourceIdOrPredicate?: string | ((item: TResource) => boolean), ): ContinuousPageChainResult { - const getResourceById = (item: TResource) => getID(item) === resourceId; - // If an id is provided, find the index of the item with that id let index = -1; - if (resourceId) { - index = sortedItems.findIndex(getResourceById); - } else if (resourceItemPredicate) { - // Otherwise, if a resourceItemPredicate is provided, find the index of the first item that matches the predicate - index = sortedItems.findLastIndex(resourceItemPredicate); + if (typeof resourceIdOrPredicate === 'string') { + index = sortedItems.findIndex((item) => getID(item) === resourceIdOrPredicate); + } else if (resourceIdOrPredicate instanceof Function) { + // Otherwise, if a predicate function is provided, find the index of the first item that matches the predicate + index = sortedItems.findLastIndex(resourceIdOrPredicate); } const itemFound = index !== -1; @@ -223,7 +220,7 @@ function getContinuousChain( }; // If we found an index or no resource item predicate was used for the search, we want link to the specific page with the item - if (itemFound || !resourceItemPredicate) { + if (itemFound || typeof resourceIdOrPredicate !== 'function') { // If we are linking to an action that doesn't exist in Onyx, return an empty array if (!itemFound) { return {data: [], hasNextPage: false, hasPreviousPage: false, resourceItem}; From 06834f359a42b3f5955f62df4324f807da52fd47 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 22:54:02 +0000 Subject: [PATCH 165/216] fix: add back external change --- src/libs/Middleware/Pagination.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index fddd31d0a142..50d034162297 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -105,7 +105,7 @@ const Pagination: Middleware = (requestResponse, request) => { const newPage = sortedPageItems.map((item) => getItemID(item)); - if (response.hasNewerActions === false) { + if (response.hasNewerActions === false || (type === 'initial' && !cursorID)) { newPage.unshift(CONST.PAGINATION_START_ID); } if (response.hasOlderActions === false || response.hasOlderActions === null) { From 641914c062749185d08fbf07f498ef71929d8c6a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 22:54:15 +0000 Subject: [PATCH 166/216] fix: simplify `getContinousChain` logic --- src/libs/PaginationUtils.ts | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index 6768fda076e8..e0d0e4023b0d 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -176,25 +176,18 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages: * * Note: sortedItems should be sorted in descending order. */ -function getContinuousChain( - sortedItems: TResource[], - pages: Pages, - getID: (item: TResource) => string, - resourceIdOrPredicate?: string | ((item: TResource) => boolean), -): ContinuousPageChainResult { +function getContinuousChain(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string, id?: string): ContinuousPageChainResult { // If an id is provided, find the index of the item with that id let index = -1; - if (typeof resourceIdOrPredicate === 'string') { - index = sortedItems.findIndex((item) => getID(item) === resourceIdOrPredicate); - } else if (resourceIdOrPredicate instanceof Function) { - // Otherwise, if a predicate function is provided, find the index of the first item that matches the predicate - index = sortedItems.findLastIndex(resourceIdOrPredicate); + + if (id) { + index = sortedItems.findIndex((item) => getID(item) === id); } - const itemFound = index !== -1; + const didFindItem = index !== -1; // If we found an item, return it as the resource item let resourceItem: ResourceItemResult | undefined; - if (itemFound) { + if (didFindItem) { const item = sortedItems.at(index); if (item) { resourceItem = { @@ -206,7 +199,7 @@ function getContinuousChain( } if (pages.length === 0) { - return {data: itemFound ? [] : [], hasNextPage: false, hasPreviousPage: false, resourceItem}; + return {data: !!id && !didFindItem ? [] : sortedItems, hasNextPage: false, hasPreviousPage: false, resourceItem}; } const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); @@ -220,18 +213,17 @@ function getContinuousChain( }; // If we found an index or no resource item predicate was used for the search, we want link to the specific page with the item - if (itemFound || typeof resourceIdOrPredicate !== 'function') { + if (id) { // If we are linking to an action that doesn't exist in Onyx, return an empty array - if (!itemFound) { + if (!didFindItem) { return {data: [], hasNextPage: false, hasPreviousPage: false, resourceItem}; } const linkedPage = pagesWithIndexes.find((pageIndex) => index >= pageIndex.firstIndex && index <= pageIndex.lastIndex); - const item = sortedItems.at(index); // If we are linked to an action in a gap return it by itself - if (!linkedPage && item) { - return {data: [item], hasNextPage: false, hasPreviousPage: false, resourceItem}; + if (!linkedPage && resourceItem) { + return {data: [resourceItem.item], hasNextPage: false, hasPreviousPage: false, resourceItem}; } if (linkedPage) { From 7b8dbbb0726a2a5f993f8329a999571e362d6437 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 17 Nov 2025 22:54:18 +0000 Subject: [PATCH 167/216] Update usePaginatedReportActions.ts --- src/hooks/usePaginatedReportActions.ts | 35 +++++++++++++++----------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index ae3052075ead..be5b141715c3 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -44,19 +44,24 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${nonEmptyStringReportID}`, {canBeMissing: true}); const initialReportLastReadTime = useRef(report?.lastReadTime); - const isUnreadReportAction = useMemo( - () => - shouldLinkToOldestUnreadReportAction - ? (reportAction: ReportAction) => { - if (!initialReportLastReadTime.current) { - return false; - } - - return reportAction.created > initialReportLastReadTime.current; - } - : undefined, - [shouldLinkToOldestUnreadReportAction], - ); + + const id = useMemo(() => { + if (reportActionID) { + return reportActionID; + } + + if (!shouldLinkToOldestUnreadReportAction) { + return undefined; + } + + return sortedAllReportActions?.findLast((reportAction) => { + if (!initialReportLastReadTime.current) { + return false; + } + + return reportAction.created > initialReportLastReadTime.current; + })?.reportActionID; + }, [reportActionID, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); const { data: reportActions, @@ -68,8 +73,8 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return {data: [], hasNextPage: false, hasPreviousPage: false}; } - return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID ?? isUnreadReportAction); - }, [isUnreadReportAction, reportActionID, reportActionPages, sortedAllReportActions]); + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, id); + }, [id, reportActionPages, sortedAllReportActions]); const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem, reportActionID]); From 6cd8f46012407e0e06a64d509266e74fe1823c95 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 09:05:52 +0100 Subject: [PATCH 168/216] fix: simplify --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index e1278b2a51fd..b902ce3e2fe3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1059,7 +1059,7 @@ function openReport( emailList: participantLoginList ? participantLoginList.join(',') : '', accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '', parentReportActionID, - transactionID: transactionID, + transactionID, useLastUnreadReportAction: true, }; From 357e222ffb33d53bebc4d4f66510a8427e7c81d9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 09:15:51 +0100 Subject: [PATCH 169/216] fix: rename Expensicons variable --- src/pages/home/ReportScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index cbb9e32ff0ec..353f88c9d412 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -153,7 +153,7 @@ function isEmpty(report: OnyxEntry): boolean { function ReportScreen({route, navigation}: ReportScreenProps) { const styles = useThemeStyles(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb'] as const); + const Expensicons = useMemoizedLazyExpensifyIcons(['Lightbulb'] as const); const {translate} = useLocalize(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); @@ -964,7 +964,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { onClose={dismissBanner} onButtonPress={chatWithAccountManager} shouldShowCloseButton - icon={expensifyIcons.Lightbulb} + icon={Expensicons.Lightbulb} shouldShowIcon shouldShowButton /> From a22aec9dc775f9a78a097a916ed302193d021fd0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 09:15:56 +0100 Subject: [PATCH 170/216] update comments --- src/libs/PaginationUtils.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index e0d0e4023b0d..dfd5eb8d782b 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -185,7 +185,7 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g } const didFindItem = index !== -1; - // If we found an item, return it as the resource item + // Return the found resource item if it exists let resourceItem: ResourceItemResult | undefined; if (didFindItem) { const item = sortedItems.at(index); @@ -212,9 +212,10 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g lastIndex: 0, }; - // If we found an index or no resource item predicate was used for the search, we want link to the specific page with the item + // If we found an item with the resource id, we want link to the specific page with the item if (id) { - // If we are linking to an action that doesn't exist in Onyx, return an empty array + // If we are searching for an item with a specific resource id and + // we are linking to an action that doesn't exist in Onyx, return an empty array if (!didFindItem) { return {data: [], hasNextPage: false, hasPreviousPage: false, resourceItem}; } @@ -230,7 +231,7 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g page = linkedPage; } } else { - // If we didn't find an item with the resourceItemPredicate or no id was provided, we want to link to the first page + // If we did not find an item with the resource id, we want to link to the first page const pageAtIndex0 = pagesWithIndexes.at(0); if (pageAtIndex0) { page = pageAtIndex0; From 482a607a3d89108c6bd660b95585721d2137d5bf Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 09:38:45 +0100 Subject: [PATCH 171/216] refactor: extract changes to separate PRs --- src/hooks/usePaginatedReportActions.ts | 50 +++----------- src/libs/PaginationUtils.ts | 69 +++++-------------- src/pages/home/ReportScreen.tsx | 3 +- src/pages/home/report/ReportActionsList.tsx | 16 ++--- src/pages/home/report/ReportActionsView.tsx | 21 +++--- .../useReportUnreadMessageScrollTracking.ts | 27 ++++---- tests/ui/PaginationTest.tsx | 57 ++++++++------- 7 files changed, 86 insertions(+), 157 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index be5b141715c3..c6020cf4fcd4 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -1,4 +1,4 @@ -import {useCallback, useMemo, useRef} from 'react'; +import {useCallback, useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import PaginationUtils from '@libs/PaginationUtils'; @@ -9,17 +9,10 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import useOnyx from './useOnyx'; import useReportIsArchived from './useReportIsArchived'; -type UsePaginatedReportActionsOptions = { - /** Whether to link to the oldest unread report action, if no other report action id is provided. */ - shouldLinkToOldestUnreadReportAction?: boolean; -}; - /** * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions. */ -function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string, options?: UsePaginatedReportActionsOptions) { - const {shouldLinkToOldestUnreadReportAction = false} = options ?? {}; - +function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string) { const nonEmptyStringReportID = getNonEmptyStringOnyxID(reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nonEmptyStringReportID}`, {canBeMissing: true}); const isReportArchived = useReportIsArchived(report?.reportID); @@ -43,52 +36,25 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? ); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${nonEmptyStringReportID}`, {canBeMissing: true}); - const initialReportLastReadTime = useRef(report?.lastReadTime); - - const id = useMemo(() => { - if (reportActionID) { - return reportActionID; - } - - if (!shouldLinkToOldestUnreadReportAction) { - return undefined; - } - - return sortedAllReportActions?.findLast((reportAction) => { - if (!initialReportLastReadTime.current) { - return false; - } - - return reportAction.created > initialReportLastReadTime.current; - })?.reportActionID; - }, [reportActionID, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); - const { data: reportActions, hasNextPage, hasPreviousPage, - resourceItem, } = useMemo(() => { if (!sortedAllReportActions?.length) { return {data: [], hasNextPage: false, hasPreviousPage: false}; } + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID); + }, [reportActionID, reportActionPages, sortedAllReportActions]); - return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, id); - }, [id, reportActionPages, sortedAllReportActions]); - - const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem, reportActionID]); - - const oldestUnreadReportAction = useMemo(() => { - if (shouldLinkToOldestUnreadReportAction && resourceItem && !reportActionID) { - return resourceItem.item; - } - return undefined; - }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID]); + const linkedAction = useMemo( + () => (reportActionID ? sortedAllReportActions?.find((reportAction) => String(reportAction.reportActionID) === String(reportActionID)) : undefined), + [reportActionID, sortedAllReportActions], + ); return { reportActions, linkedAction, - oldestUnreadReportAction, sortedAllReportActions, hasOlderActions: hasNextPage, hasNewerActions: hasPreviousPage, diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index dfd5eb8d782b..98b9451fe970 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -25,19 +25,6 @@ type ItemWithIndex = { index: number; }; -type ResourceItemResult = { - index: number; - id: string; - item: TResource; -}; - -type ContinuousPageChainResult = { - data: TResource[]; - hasNextPage: boolean; - hasPreviousPage: boolean; - resourceItem?: ResourceItemResult; -}; - /** * Finds the id and index in sortedItems of the first item in the given page that's present in sortedItems. */ @@ -176,30 +163,15 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages: * * Note: sortedItems should be sorted in descending order. */ -function getContinuousChain(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string, id?: string): ContinuousPageChainResult { - // If an id is provided, find the index of the item with that id - let index = -1; - - if (id) { - index = sortedItems.findIndex((item) => getID(item) === id); - } - const didFindItem = index !== -1; - - // Return the found resource item if it exists - let resourceItem: ResourceItemResult | undefined; - if (didFindItem) { - const item = sortedItems.at(index); - if (item) { - resourceItem = { - index, - item, - id: getID(item), - }; - } - } - +function getContinuousChain( + sortedItems: TResource[], + pages: Pages, + getID: (item: TResource) => string, + id?: string, +): {data: TResource[]; hasNextPage: boolean; hasPreviousPage: boolean} { if (pages.length === 0) { - return {data: !!id && !didFindItem ? [] : sortedItems, hasNextPage: false, hasPreviousPage: false, resourceItem}; + const dataItem = sortedItems.find((item) => getID(item) === id); + return {data: id && !dataItem ? [] : sortedItems, hasNextPage: false, hasPreviousPage: false}; } const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); @@ -212,26 +184,26 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g lastIndex: 0, }; - // If we found an item with the resource id, we want link to the specific page with the item if (id) { - // If we are searching for an item with a specific resource id and - // we are linking to an action that doesn't exist in Onyx, return an empty array - if (!didFindItem) { - return {data: [], hasNextPage: false, hasPreviousPage: false, resourceItem}; + const index = sortedItems.findIndex((item) => getID(item) === id); + + // If we are linking to an action that doesn't exist in Onyx, return an empty array + if (index === -1) { + return {data: [], hasNextPage: false, hasPreviousPage: false}; } const linkedPage = pagesWithIndexes.find((pageIndex) => index >= pageIndex.firstIndex && index <= pageIndex.lastIndex); + const item = sortedItems.at(index); // If we are linked to an action in a gap return it by itself - if (!linkedPage && resourceItem) { - return {data: [resourceItem.item], hasNextPage: false, hasPreviousPage: false, resourceItem}; + if (!linkedPage && item) { + return {data: [item], hasNextPage: false, hasPreviousPage: false}; } if (linkedPage) { page = linkedPage; } } else { - // If we did not find an item with the resource id, we want to link to the first page const pageAtIndex0 = pagesWithIndexes.at(0); if (pageAtIndex0) { page = pageAtIndex0; @@ -239,15 +211,10 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g } if (!page) { - return {data: sortedItems, hasNextPage: false, hasPreviousPage: false, resourceItem}; + return {data: sortedItems, hasNextPage: false, hasPreviousPage: false}; } - return { - data: sortedItems.slice(page.firstIndex, page.lastIndex + 1), - hasNextPage: page.lastID !== CONST.PAGINATION_END_ID, - hasPreviousPage: page.firstID !== CONST.PAGINATION_START_ID, - resourceItem, - }; + return {data: sortedItems.slice(page.firstIndex, page.lastIndex + 1), hasNextPage: page.lastID !== CONST.PAGINATION_END_ID, hasPreviousPage: page.firstID !== CONST.PAGINATION_START_ID}; } export default {mergeAndSortContinuousPages, getContinuousChain}; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 353f88c9d412..bd63bbf02792 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -163,7 +163,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const firstRenderRef = useRef(true); const [firstRender, setFirstRender] = useState(true); const isSkippingOpenReport = useRef(false); - const flatListRef = useRef | null>(null); + const flatListRef = useRef(null); const {isBetaEnabled} = usePermissions(); const {isOffline} = useNetwork(); const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); @@ -308,7 +308,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { // wrapping in useMemo because this is array operation and can cause performance issues const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); - const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`, {canBeMissing: true}); const [isBannerVisible, setIsBannerVisible] = useState(true); diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 1be3cb6ca26f..60d3671eff2e 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -363,14 +363,6 @@ function ReportActionsList({ const previousLastIndex = useRef(lastActionIndex); const sortedVisibleReportActionsRef = useRef(sortedVisibleReportActions); - const trackScrolling = (event: NativeSyntheticEvent) => { - scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; - onScroll?.(event); - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { - setShouldScrollToEndAfterLayout(false); - } - }; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, currentVerticalScrollingOffsetRef: scrollingVerticalOffset, @@ -378,7 +370,13 @@ function ReportActionsList({ hasNewerActions, unreadMarkerReportActionIndex, isInverted: true, - onTrackScrolling: trackScrolling, + onTrackScrolling: (event: NativeSyntheticEvent) => { + scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; + onScroll?.(event); + if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { + setShouldScrollToEndAfterLayout(false); + } + }, }); useEffect(() => { diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 2bb264321391..69c6c24fc69f 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -103,8 +103,8 @@ function ReportActionsView({ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const prevTransactionThreadReport = usePrevious(transactionThreadReport); - const reportActionIDFromRoute = route?.params?.reportActionID; - const prevReportActionIDFromRoute = usePrevious(reportActionIDFromRoute); + const reportActionID = route?.params?.reportActionID; + const prevReportActionID = usePrevious(reportActionID); const reportPreviewAction = useMemo(() => getReportPreviewAction(report.chatReportID, report.reportID), [report.chatReportID, report.reportID]); const didLayout = useRef(false); const {isOffline} = useNetwork(); @@ -127,15 +127,15 @@ function ReportActionsView({ useEffect(() => { // When we linked to message - we do not need to wait for initial actions - they already exists - if (!reportActionIDFromRoute || !isOffline) { + if (!reportActionID || !isOffline) { return; } updateLoadingInitialReportAction(report.reportID); - }, [isOffline, report.reportID, reportActionIDFromRoute]); + }, [isOffline, report.reportID, reportActionID]); // Change the list ID only for comment linking to get the positioning right const listID = useMemo(() => { - if (!reportActionIDFromRoute && !prevReportActionIDFromRoute) { + if (!reportActionID && !prevReportActionID) { // Keep the old list ID since we're not in the Comment Linking flow return listOldID; } @@ -145,7 +145,7 @@ function ReportActionsView({ return newID; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [route, reportActionIDFromRoute]); + }, [route, reportActionID]); // When we are offline before opening an IOU/Expense report, // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. @@ -252,6 +252,7 @@ function ReportActionsView({ const {loadOlderChats, loadNewerChats} = useLoadReportActions({ reportID, + reportActionID, reportActions, allReportActionIDs, transactionThreadReport, @@ -273,12 +274,12 @@ function ReportActionsView({ }, [reportID]); // Check if the first report action in the list is the one we're currently linked to - const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionIDFromRoute; + const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; useEffect(() => { let timerID: NodeJS.Timeout; - if (!isTheFirstReportActionIsLinked && reportActionIDFromRoute) { + if (!isTheFirstReportActionIsLinked && reportActionID) { setNavigatingToLinkedMessage(true); // After navigating to the linked reportAction, apply this to correctly set // `autoscrollToTopThreshold` prop when linking to a specific reportAction. @@ -297,7 +298,7 @@ function ReportActionsView({ } clearTimeout(timerID); }; - }, [isTheFirstReportActionIsLinked, reportActionIDFromRoute]); + }, [isTheFirstReportActionIsLinked, reportActionID]); // Show skeleton while loading initial report actions when data is incomplete/missing and online const shouldShowSkeletonForInitialLoad = isLoadingInitialReportActions && (isReportDataIncomplete || isMissingReportActions) && !isOffline; @@ -314,7 +315,7 @@ function ReportActionsView({ } // AutoScroll is disabled when we do linking to a specific reportAction - const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionIDFromRoute || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); + const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); return ( <> CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && + !isFloatingMessageCounterVisible && + !hasUnreadMarkerReportAction + ) { setIsFloatingMessageCounterVisible(true); } - // Hide floating button if we're scrolled closer than the offset and mark message as read - if (isScrolledToEnd && !hasUnreadMarkerReportAction && isFloatingMessageCounterVisible && !hasNewerActions) { - if (readActionSkippedRef.current) { - // eslint-disable-next-line react-compiler/react-compiler,no-param-reassign - readActionSkippedRef.current = false; - readNewestAction(reportID); - } - + // hide floating button if we're scrolled closer than the offset + if ( + currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && + isFloatingMessageCounterVisible && + !hasUnreadMarkerReportAction && + ) { setIsFloatingMessageCounterVisible(false); } }; diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 42d6e98bc193..f6f7e7e658e0 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -6,7 +6,6 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import {setSidebarLoaded} from '@libs/actions/App'; import {subscribeToUserEvents} from '@libs/actions/User'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {waitForIdle} from '@libs/Network/SequentialQueue'; import App from '@src/App'; import CONST from '@src/CONST'; @@ -84,7 +83,7 @@ function buildReportComments(count: number, initialID: string, reverse = false) } function mockOpenReport(messageCount: number, initialID: string) { - fetchMock.mockAPICommand(WRITE_COMMANDS.OPEN_REPORT, ({reportID, reportActionID}) => { + fetchMock.mockAPICommand('OpenReport', ({reportID, reportActionID}) => { const comments = buildReportComments(messageCount, initialID); return { onyxData: @@ -105,7 +104,7 @@ function mockOpenReport(messageCount: number, initialID: string) { } function mockGetOlderActions(messageCount: number) { - fetchMock.mockAPICommand(READ_COMMANDS.GET_OLDER_ACTIONS, ({reportID, reportActionID}) => { + fetchMock.mockAPICommand('GetOlderActions', ({reportID, reportActionID}) => { // The API also returns the action that was requested with the reportActionID. const comments = buildReportComments(messageCount + 1, reportActionID); return { @@ -125,7 +124,7 @@ function mockGetOlderActions(messageCount: number) { } function mockGetNewerActions(messageCount: number) { - fetchMock.mockAPICommand(READ_COMMANDS.GET_NEWER_ACTIONS, ({reportID, reportActionID}) => ({ + fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => ({ onyxData: reportID === REPORT_ID ? [ @@ -257,10 +256,10 @@ describe('Pagination', () => { await navigateToSidebarOption(REPORT_ID); expect(getReportActions()).toHaveLength(5); - TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); - TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.OPEN_REPORT, 0, {reportID: REPORT_ID}); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID}); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should not trigger a new network request. scrollToOffset(LIST_CONTENT_SIZE.height); @@ -268,9 +267,9 @@ describe('Pagination', () => { scrollToOffset(0); await waitForBatchedUpdatesWithAct(); - TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); }); it('opens a chat and load older messages', async () => { @@ -281,19 +280,19 @@ describe('Pagination', () => { await navigateToSidebarOption(REPORT_ID); expect(getReportActions()).toHaveLength(CONST.REPORT.MIN_INITIAL_REPORT_ACTION_COUNT); - TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); - TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.OPEN_REPORT, 0, {reportID: REPORT_ID}); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID}); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should trigger a new network request. scrollToOffset(LIST_CONTENT_SIZE.height); await waitForBatchedUpdatesWithAct(); - TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 1); - TestHelper.expectAPICommandToHaveBeenCalledWith(READ_COMMANDS.GET_OLDER_ACTIONS, 0, {reportID: REPORT_ID, reportActionID: '4'}); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 1); + TestHelper.expectAPICommandToHaveBeenCalledWith('GetOlderActions', 0, {reportID: REPORT_ID, reportActionID: '4'}); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); await waitForNetworkPromises(); await waitForBatchedUpdatesWithAct(); @@ -328,13 +327,13 @@ describe('Pagination', () => { expect(getReportActions()).toHaveLength(10); // There is 1 extra call here because of the comment linking report. - TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 3); - TestHelper.expectAPICommandToHaveBeenCalledWith(WRITE_COMMANDS.OPEN_REPORT, 1, {reportID: REPORT_ID, reportActionID: '5'}); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); - TestHelper.expectAPICommandToHaveBeenCalledWith(READ_COMMANDS.GET_NEWER_ACTIONS, 0, {reportID: REPORT_ID, reportActionID: '5'}); // Simulate the backend returning no new messages to simulate reaching the start of the chat. mockGetNewerActions(0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); + TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 1, {reportID: REPORT_ID, reportActionID: '5'}); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalledWith('GetNewerActions', 0, {reportID: REPORT_ID, reportActionID: '5'}); // Simulate the maintainVisibleContentPosition scroll adjustment, so it is now possible to scroll down more. scrollToOffset(500); @@ -345,9 +344,9 @@ describe('Pagination', () => { // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); - TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 3); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 2); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); scrollToOffset(500); await waitForBatchedUpdatesWithAct(); @@ -355,9 +354,9 @@ describe('Pagination', () => { await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. - TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 3); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_OLDER_ACTIONS, 0); - TestHelper.expectAPICommandToHaveBeenCalled(READ_COMMANDS.GET_NEWER_ACTIONS, 2); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 1); // We still have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); From 618e933fd0aa38984e0d0f0d9f17192b2f702a7c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 09:43:56 +0100 Subject: [PATCH 172/216] refactor: extract more changes to separate PRs --- tests/ui/PaginationTest.tsx | 54 +++++++++++++++++++++++++- tests/utils/ReportTestUtils.ts | 71 +--------------------------------- 2 files changed, 54 insertions(+), 71 deletions(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index f6f7e7e658e0..35823381c0eb 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -13,7 +13,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; import PusherHelper from '../utils/PusherHelper'; -import {getReportScreen, LIST_CONTENT_SIZE, navigateToSidebarOption, REPORT_ID, scrollToOffset, triggerListLayout} from '../utils/ReportTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -34,8 +33,17 @@ jest.mock('@libs/Navigation/AppNavigator/usePreloadFullScreenNavigators', () => TestHelper.setupApp(); const fetchMock = TestHelper.setupGlobalFetchMock(); +const LIST_SIZE = { + width: 300, + height: 400, +}; +const LIST_CONTENT_SIZE = { + width: 300, + height: 600, +}; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); +const REPORT_ID = '1'; const COMMENT_LINKING_REPORT_ID = '2'; const USER_A_ACCOUNT_ID = 1; const USER_A_EMAIL = 'user_a@test.com'; @@ -44,6 +52,39 @@ const USER_B_EMAIL = 'user_b@test.com'; const TEST_AUTH_TOKEN = 'test-auth-token'; const TEST_AUTO_GENERATED_LOGIN = 'expensify.cash-abc123'; +function getReportScreen(reportID = REPORT_ID) { + return screen.getByTestId(`report-screen-${reportID}`); +} + +function scrollToOffset(offset: number) { + const hintText = TestHelper.translateLocal('sidebarScreen.listOfChatMessages'); + fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { + nativeEvent: { + contentOffset: { + y: offset, + }, + contentSize: LIST_CONTENT_SIZE, + layoutMeasurement: LIST_SIZE, + }, + }); +} + +function triggerListLayout(reportID?: string) { + const report = getReportScreen(reportID); + fireEvent(within(report).getByTestId('report-actions-view-wrapper'), 'onLayout', { + nativeEvent: { + layout: { + x: 0, + y: 0, + ...LIST_SIZE, + }, + }, + persist: () => {}, + }); + + fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); +} + function getReportActions(reportID?: string) { const report = getReportScreen(reportID); return [ @@ -53,6 +94,17 @@ function getReportActions(reportID?: string) { ]; } +async function navigateToSidebarOption(reportID: string): Promise { + const optionRow = screen.getByTestId(reportID); + fireEvent(optionRow, 'press'); + await waitFor(() => { + (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); + }); + // ReportScreen relies on the onLayout event to receive updates from onyx. + triggerListLayout(reportID); + await waitForBatchedUpdatesWithAct(); +} + function buildCreatedAction(reportActionID: string, created: string) { return { reportActionID, diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts index 04ec800316eb..dfdbfeb6f963 100644 --- a/tests/utils/ReportTestUtils.ts +++ b/tests/utils/ReportTestUtils.ts @@ -1,12 +1,7 @@ -import * as NativeNavigation from '@react-navigation/native'; -import {fireEvent, screen, waitFor, within} from '@testing-library/react-native'; import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; import type ReportActionName from '@src/types/onyx/ReportActionName'; -import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; import createRandomReportAction from './collections/reportActions'; -import * as TestHelper from './TestHelper'; -import waitForBatchedUpdatesWithAct from './waitForBatchedUpdatesWithAct'; const actionNames: ReportActionName[] = [CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, CONST.REPORT.ACTIONS.TYPE.CLOSED]; @@ -76,68 +71,4 @@ const getMockedReportActionsMap = (length = 100): ReportActions => { return Object.assign({}, ...mockReports) as ReportActions; }; -const REPORT_ID = '1'; -const LIST_SIZE = { - width: 300, - height: 400, -}; -const LIST_CONTENT_SIZE = { - width: 300, - height: 600, -}; - -function getReportScreen(reportID = REPORT_ID) { - return screen.getByTestId(`report-screen-${reportID}`); -} - -function scrollToOffset(offset: number) { - const hintText = TestHelper.translateLocal('sidebarScreen.listOfChatMessages'); - fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { - nativeEvent: { - contentOffset: { - y: offset, - }, - contentSize: LIST_CONTENT_SIZE, - layoutMeasurement: LIST_SIZE, - }, - }); -} - -function triggerListLayout(reportID?: string) { - const report = getReportScreen(reportID); - fireEvent(within(report).getByTestId('report-actions-view-wrapper'), 'onLayout', { - nativeEvent: { - layout: { - x: 0, - y: 0, - ...LIST_SIZE, - }, - }, - persist: () => {}, - }); - - fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); -} - -async function navigateToSidebarOption(reportID: string): Promise { - const optionRow = screen.getByTestId(reportID); - fireEvent(optionRow, 'press'); - await waitFor(() => { - (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); - }); - // ReportScreen relies on the onLayout event to receive updates from onyx. - triggerListLayout(reportID); - await waitForBatchedUpdatesWithAct(); -} - -export { - getFakeReportAction, - getMockedSortedReportActions, - getMockedReportActionsMap, - REPORT_ID, - LIST_CONTENT_SIZE, - getReportScreen, - scrollToOffset, - triggerListLayout, - navigateToSidebarOption, -}; +export {getFakeReportAction, getMockedSortedReportActions, getMockedReportActionsMap}; From 290df1e6675e307fcf7a7197370e205278c5836c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 09:51:27 +0100 Subject: [PATCH 173/216] fix: missing condition --- src/pages/home/report/useReportUnreadMessageScrollTracking.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts index 64e5f46c724c..4278f5383be6 100644 --- a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -81,6 +81,7 @@ export default function useReportUnreadMessageScrollTracking({ currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible && !hasUnreadMarkerReportAction && + !hasNewerActions ) { setIsFloatingMessageCounterVisible(false); } From 99d81520b95d7784f1a67810698c9f85c7e1af31 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 09:57:31 +0100 Subject: [PATCH 174/216] refactor: move more changes --- .../BaseInvertedFlatList/index.tsx | 52 +++++++++++++-- src/hooks/useFlatListHandle.ts | 64 ------------------- 2 files changed, 48 insertions(+), 68 deletions(-) delete mode 100644 src/hooks/useFlatListHandle.ts diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 70e66e696d51..eb1c6ddced04 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,9 +1,7 @@ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; -import useFlatListHandle from '@hooks/useFlatListHandle'; -import type {FlatListInnerRefType} from '@hooks/useFlatListHandle'; import usePrevious from '@hooks/usePrevious'; import type {RenderInfo} from './RenderTaskQueue'; import RenderTaskQueue from './RenderTaskQueue'; @@ -24,6 +22,8 @@ function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: n return String(index); } +type FlatListInnerRefType = RNFlatList & HTMLElement; + type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' | 'initialScrollIndex'> & { shouldEnableAutoScrollToTopThreshold?: boolean; data: T[]; @@ -95,7 +95,6 @@ function BaseInvertedFlatList({ const remainingItemsToDisplay = data.length - displayedData.length; const listRef = useRef>(null); - useFlatListHandle({forwardedRef: ref, listRef, setCurrentDataId, remainingItemsToDisplay, onScrollToIndexFailed}); const [didInitialContentRender, setDidInitialContentRender] = useState(false); const handleContentSizeChange = useCallback( @@ -182,6 +181,51 @@ function BaseInvertedFlatList({ }; }, [displayedData.length, initialScrollKey, isInitialData, isLoadingData, isQueueRendering, shouldEnableAutoScrollToTopThreshold, wasLoadingData]); + useImperativeHandle(ref, () => { + // If we're trying to scroll at the start of the list we need to make sure to + // render all items. + const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { + if (params.offset === 0) { + setCurrentDataId(null); + } + + requestAnimationFrame(() => { + listRef.current?.scrollToOffset(params); + }); + }; + + const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => { + const actualIndex = params.index - remainingItemsToDisplay; + try { + listRef.current?.scrollToIndex({...params, index: actualIndex}); + } catch (ex) { + // It is possible that scrolling fails since the item we are trying to scroll to + // has not been rendered yet. In this case, we call the onScrollToIndexFailed. + onScrollToIndexFailed?.({ + index: actualIndex, + // These metrics are not implemented. + averageItemLength: 0, + highestMeasuredFrameIndex: 0, + }); + } + }; + + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'scrollToOffset') { + return scrollToOffsetFn; + } + if (prop === 'scrollToIndex') { + return scrollToIndexFn; + } + return listRef.current?.[prop as keyof RNFlatList]; + }, + }, + ) as RNFlatList; + }); + return ( = RNFlatList & HTMLElement; - -type UseFlatListHandleProps = { - forwardedRef: ForwardedRef | undefined; - listRef: React.RefObject | null>; - setCurrentDataId: (dataId: string | null) => void; - remainingItemsToDisplay: number; - onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; -}; - -function useFlatListHandle({forwardedRef, listRef, setCurrentDataId, remainingItemsToDisplay, onScrollToIndexFailed}: UseFlatListHandleProps) { - useImperativeHandle(forwardedRef, () => { - // If we're trying to scroll at the start of the list we need to make sure to - // render all items. - const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => { - if (params.offset === 0) { - setCurrentDataId(null); - } - - requestAnimationFrame(() => { - listRef.current?.scrollToOffset(params); - }); - }; - - const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => { - const actualIndex = params.index - remainingItemsToDisplay; - try { - listRef.current?.scrollToIndex({...params, index: actualIndex}); - } catch (ex) { - // It is possible that scrolling fails since the item we are trying to scroll to - // has not been rendered yet. In this case, we call the onScrollToIndexFailed. - onScrollToIndexFailed?.({ - index: actualIndex, - // These metrics are not implemented. - averageItemLength: 0, - highestMeasuredFrameIndex: 0, - }); - } - }; - - return new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'scrollToOffset') { - return scrollToOffsetFn; - } - if (prop === 'scrollToIndex') { - return scrollToIndexFn; - } - return listRef.current?.[prop as keyof RNFlatList]; - }, - }, - ) as RNFlatList; - }); -} - -export default useFlatListHandle; - -export type {FlatListInnerRefType}; From a38b82f8c94549d78189f990d3afa35106dc3854 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 26 Nov 2025 10:19:22 +0100 Subject: [PATCH 175/216] refactor: extract changes --- src/hooks/useLoadReportActions.ts | 113 +++++++----------------------- 1 file changed, 24 insertions(+), 89 deletions(-) diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 95fa27eebcf4..14755d8655c2 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useMemo, useRef} from 'react'; +import {useCallback, useMemo, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {getNewerActions, getOlderActions} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -11,6 +11,9 @@ type UseLoadReportActionsArguments = { /** The id of the current report */ reportID: string; + /** The id of the reportAction (if specific action was linked to */ + reportActionID?: string; + /** The list of reportActions linked to the current report */ reportActions: ReportAction[]; @@ -31,17 +34,16 @@ type UseLoadReportActionsArguments = { * Provides reusable logic to get the functions for loading older/newer reportActions. * Used in the report displaying components */ -function useLoadReportActions({reportID, reportActions, allReportActionIDs, transactionThreadReport, hasOlderActions, hasNewerActions}: UseLoadReportActionsArguments) { - const isLoadingNewerChats = useRef(false); - const isLoadingOlderChats = useRef(false); +function useLoadReportActions({reportID, reportActionID, reportActions, allReportActionIDs, transactionThreadReport, hasOlderActions, hasNewerActions}: UseLoadReportActionsArguments) { + const didLoadOlderChats = useRef(false); + const didLoadNewerChats = useRef(false); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); + const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); - const isTransactionThreadReport = !isEmptyObject(transactionThreadReport); - // Track oldest/newest actions per report in a single pass const {currentReportOldest, currentReportNewest, transactionThreadOldest, transactionThreadNewest} = useMemo(() => { let currentReportNewestAction = null; @@ -64,7 +66,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran } // Oldest = last matching action we encounter currentReportOldestAction = action; - } else if (isTransactionThreadReport && transactionThreadReport?.reportID === targetReportID) { + } else if (!isEmptyObject(transactionThreadReport) && transactionThreadReport?.reportID === targetReportID) { // Same logic for transaction thread if (!transactionThreadNewestAction) { transactionThreadNewestAction = action; @@ -79,18 +81,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran transactionThreadOldest: transactionThreadOldestAction, transactionThreadNewest: transactionThreadNewestAction, }; - }, [allReportActionIDs, reportActions, reportID, transactionThreadReport?.reportID, isTransactionThreadReport]); - - const isReportActionLoaded = useCallback( - (actionID: string | undefined) => { - if (!actionID) { - return true; - } - - return reportActions.some((action) => action.reportActionID === actionID); - }, - [reportActions], - ); + }, [reportActions, allReportActionIDs, reportID, transactionThreadReport]); /** * Retrieves the next set of reportActions for the chat once we are nearing the end of what we are currently @@ -99,7 +90,7 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran const loadOlderChats = useCallback( (force = false) => { // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. - if (!force && (isOffline || isLoadingOlderChats.current)) { + if (!force && isOffline) { return; } @@ -108,62 +99,38 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran return; } - isLoadingOlderChats.current = true; + didLoadOlderChats.current = true; - getOlderActions(reportID, currentReportOldest?.reportActionID); - if (isTransactionThreadReport) { + if (!isEmptyObject(transactionThreadReport)) { + getOlderActions(reportID, currentReportOldest?.reportActionID); getOlderActions(transactionThreadReport.reportID, transactionThreadOldest?.reportActionID); + } else { + getOlderActions(reportID, currentReportOldest?.reportActionID); } }, - [ - isOffline, - oldestReportAction, - hasOlderActions, - reportID, - currentReportOldest?.reportActionID, - isTransactionThreadReport, - transactionThreadReport?.reportID, - transactionThreadOldest?.reportActionID, - ], + [isOffline, oldestReportAction, hasOlderActions, transactionThreadReport, reportID, currentReportOldest?.reportActionID, transactionThreadOldest?.reportActionID], ); - useEffect(() => { - if (!isLoadingOlderChats.current) { - return; - } - - const isOldestReportActionLoaded = isReportActionLoaded(currentReportOldest?.reportActionID); - - if (!isTransactionThreadReport && isOldestReportActionLoaded) { - isLoadingOlderChats.current = false; - return; - } - - const isOldestTransactionThreadReportActionLoaded = isReportActionLoaded(transactionThreadOldest?.reportActionID); - if (isOldestReportActionLoaded && isOldestTransactionThreadReportActionLoaded) { - isLoadingOlderChats.current = false; - } - }, [currentReportOldest?.reportActionID, isReportActionLoaded, isTransactionThreadReport, reportActions, transactionThreadOldest?.reportActionID, transactionThreadReport]); - const loadNewerChats = useCallback( (force = false) => { if ( !force && - (!isFocused || + (!reportActionID || + !isFocused || !newestReportAction || !hasNewerActions || isOffline || - isLoadingNewerChats.current || // If there was an error only try again once on initial mount. We should also still load // more in case we have cached messages. + didLoadNewerChats.current || newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) ) { return; } - isLoadingNewerChats.current = true; + didLoadNewerChats.current = true; - if (isTransactionThreadReport) { + if (!isEmptyObject(transactionThreadReport)) { getNewerActions(reportID, currentReportNewest?.reportActionID); getNewerActions(transactionThreadReport.reportID, transactionThreadNewest?.reportActionID); } else if (newestReportAction) { @@ -171,50 +138,18 @@ function useLoadReportActions({reportID, reportActions, allReportActionIDs, tran } }, [ + reportActionID, isFocused, newestReportAction, hasNewerActions, isOffline, - isTransactionThreadReport, + transactionThreadReport, reportID, currentReportNewest?.reportActionID, - transactionThreadReport?.reportID, transactionThreadNewest?.reportActionID, ], ); - useEffect(() => { - if (!isLoadingNewerChats.current) { - return; - } - - if (!isTransactionThreadReport) { - const isNewestReportActionLoaded = isReportActionLoaded(currentReportNewest?.reportActionID); - isLoadingNewerChats.current = false; - const isNewestTransactionThreadReportActionLoaded = isReportActionLoaded(transactionThreadNewest?.reportActionID); - - if (isNewestReportActionLoaded && isNewestTransactionThreadReportActionLoaded) { - isLoadingNewerChats.current = false; - } - - return; - } - - const isNewestReportActionLoaded = isReportActionLoaded(newestReportAction?.reportActionID); - - if (isNewestReportActionLoaded) { - isLoadingNewerChats.current = false; - } - }, [ - currentReportNewest?.reportActionID, - isReportActionLoaded, - isTransactionThreadReport, - newestReportAction?.reportActionID, - reportActions, - transactionThreadNewest?.reportActionID, - transactionThreadReport, - ]); - return { loadOlderChats, loadNewerChats, From b2d705d3ee24310f1377fe6e96d18266bdd813bb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 20:56:39 +0000 Subject: [PATCH 176/216] Update Mobile-Expensify --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index e30a1836e53c..3417b74693a3 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit e30a1836e53c5fc5c568b211727827aa287cfeb0 +Subproject commit 3417b74693a35ae76f5a76c68651b6552b9051fa From 9d06d14b2294158e3408dc8a19bc45a9942e88b7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 21:06:15 +0000 Subject: [PATCH 177/216] fix: TS and ESLint errors --- src/libs/API/parameters/OpenReportParams.ts | 1 - src/pages/inbox/report/ReportActionsList.tsx | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/API/parameters/OpenReportParams.ts b/src/libs/API/parameters/OpenReportParams.ts index d1cfdeaf575a..7c3ab73bab06 100644 --- a/src/libs/API/parameters/OpenReportParams.ts +++ b/src/libs/API/parameters/OpenReportParams.ts @@ -15,7 +15,6 @@ type OpenReportParams = { optimisticAccountIDList?: string; file?: File | CustomRNImageManipulatorResult; guidedSetupData?: string; - useLastUnreadReportAction?: boolean; /** * The ID of the unreported transaction to create a thread for. * Used when displaying unreported expenses that have no transaction thread associated with them. diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 02539e4acbfe..97d64fa0ea7c 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -425,7 +425,7 @@ function ReportActionsList({ didMarkReportAsReadInitially.current = true; readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions); - }, [isListInitiallyLoaded, isReportUnread, report.reportID]); + }, [isListInitiallyLoaded, isReportUnread, report.reportID, reportMetadata?.hasOnceLoadedReportActions]); const handleReportChangeMarkAsRead = useCallback(() => { if (report.reportID !== prevReportID) { @@ -788,7 +788,6 @@ function ReportActionsList({ styles, translate, expensifyIcons.UpArrow, - isOffline, ], ); From deca4e533f9a1c4f0cf697097f811f199f07c6e3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 21:06:42 +0000 Subject: [PATCH 178/216] fix: ESLint --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index a79323ba7272..ac4ca6b21d25 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -716,7 +716,6 @@ function MoneyRequestReportActionsList({ isReportArchived, reportNameValuePairs?.origin, reportNameValuePairs?.originalID, - isOffline, ], ); From 6ecce8c38bf0e9be61f02dd0e1d65295124ed487 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 25 Mar 2026 21:07:26 +0000 Subject: [PATCH 179/216] revert: API changes --- src/libs/API/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index c7c18e230bd0..9c1831b8456d 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -292,11 +292,13 @@ function paginate processRequest(request, type)); + waitForWrites(command as ReadCommand).then(() => processRequest(request, type)); + return; default: throw new Error('Unknown API request type'); } From 1eca4990b57e73d894ab5a18793397ff856d9c8d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 26 Mar 2026 11:29:02 +0000 Subject: [PATCH 180/216] fix: prevent page merging when opening unread report action --- src/libs/Middleware/Pagination.ts | 24 ++++- src/libs/PaginationUtils.ts | 157 +++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index e79112dbb4da..e3b6214b7675 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -3,10 +3,11 @@ import type {OnyxCollection, OnyxKey} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ApiCommand} from '@libs/API/types'; import Log from '@libs/Log'; -import PaginationUtils from '@libs/PaginationUtils'; +import {mergeAndSortContinuousPages, mergePagesByIDOverlap} from '@libs/PaginationUtils'; import CONST from '@src/CONST'; import type {OnyxCollectionKey, OnyxPagesKey, OnyxValues} from '@src/ONYXKEYS'; import type {Request} from '@src/types/onyx'; +import type Pages from '@src/types/onyx/Pages'; import type {AnyOnyxUpdate, PaginatedRequest} from '@src/types/onyx/Request'; import type Middleware from './types'; @@ -109,7 +110,7 @@ const Pagination: Middleware = (requestResponse, request) => { const newPage = sortedPageItems.map((item) => getItemID(item)); - if (response.hasNewerActions === false || response.hasNewerActions === null || (type === 'initial' && !cursorID)) { + if (response.hasNewerActions === false || response.hasNewerActions === null) { newPage.unshift(CONST.PAGINATION_START_ID); } if (response.hasOlderActions === false || response.hasOlderActions === null) { @@ -122,7 +123,19 @@ const Pagination: Middleware = (requestResponse, request) => { const sortedAllItems = sortItems(allItems, resourceID); const pagesCollections = pages.get(pageCollectionKey) ?? {}; - const existingPages = pagesCollections[pageKey] ?? []; + const existingPages: Pages = pagesCollections[pageKey] ?? []; + + // Some tooling resolves `@libs/PaginationUtils` as untyped, so we add explicit local types + // to avoid unsafe-call/assignment linting while keeping runtime behavior identical. + const mergePagesByIDOverlapTyped: (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages = mergePagesByIDOverlap as unknown as < + TResource, + >( + sortedItems: TResource[], + pages: Pages, + getItemID: (item: TResource) => string, + ) => Pages; + const mergeAndSortContinuousPagesTyped: (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages = + mergeAndSortContinuousPages as unknown as (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages; // When loading the first page of data, make sure to remove the start maker if the backend returns // that there is new data. @@ -130,7 +143,10 @@ const Pagination: Middleware = (requestResponse, request) => { if (type === 'initial' && !cursorID && firstPage?.at(0) === CONST.PAGINATION_START_ID && response.hasNewerActions === true) { firstPage.shift(); } - const mergedPages = PaginationUtils.mergeAndSortContinuousPages(sortedAllItems, [...existingPages, newPage], getItemID); + const isMiddleInitialSlice = type === 'initial' && !cursorID && response.hasNewerActions === true && response.hasOlderActions === true; + const mergedPages: Pages = isMiddleInitialSlice + ? mergePagesByIDOverlapTyped(sortedAllItems, [...existingPages, newPage], getItemID) + : mergeAndSortContinuousPagesTyped(sortedAllItems, [...existingPages, newPage], getItemID); (response.onyxData as AnyOnyxUpdate[]).push({ key: pageKey, diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index dfd5eb8d782b..32689854542c 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -1,6 +1,20 @@ import CONST from '@src/CONST'; import type Pages from '@src/types/onyx/Pages'; +function isPaginationMarker(id: string): boolean { + return id === CONST.PAGINATION_START_ID || id === CONST.PAGINATION_END_ID; +} + +function buildIDToIndexMap(sortedItems: TResource[], getID: (item: TResource) => string): Map { + const map = new Map(); + let index = 0; + for (const item of sortedItems) { + map.set(getID(item), index); + index++; + } + return map; +} + type PageWithIndex = { /** The IDs we store in Onyx and which make up the page. */ ids: string[]; @@ -169,6 +183,145 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages: return result.map((page) => page?.ids ?? []); } +type PageWithSortKey = { + ids: string[]; + firstIndex: number; + lastIndex: number; +}; + +function getFirstAndLastIndexForPage(page: string[], idToIndex: Map, lastIndexInSortedItems: number): {firstIndex: number; lastIndex: number} | null { + let firstIndex: number | undefined; + let lastIndex: number | undefined; + + for (const id of page) { + if (id === CONST.PAGINATION_START_ID) { + firstIndex = 0; + continue; + } + + const index = idToIndex.get(id); + if (index === undefined) { + continue; + } + + if (firstIndex === undefined || index < firstIndex) { + firstIndex = index; + } + if (lastIndex === undefined || index > lastIndex) { + lastIndex = index; + } + } + + for (let i = page.length - 1; i >= 0; i--) { + const id = page.at(i); + if (id === CONST.PAGINATION_END_ID) { + lastIndex = lastIndexInSortedItems; + break; + } + } + + if (firstIndex === undefined || lastIndex === undefined) { + return null; + } + + return {firstIndex, lastIndex}; +} + +function pagesShareAnyNonMarkerID(pageA: string[], pageB: string[]): boolean { + const a = pageA.filter((id) => !isPaginationMarker(id)); + const b = pageB.filter((id) => !isPaginationMarker(id)); + + if (a.length === 0 || b.length === 0) { + return false; + } + + const [smaller, larger] = a.length <= b.length ? [a, b] : [b, a]; + const set = new Set(smaller); + for (const id of larger) { + if (set.has(id)) { + return true; + } + } + return false; +} + +function mergeTwoPagesByUnionAndSort(sortedItems: TResource[], pageA: string[], pageB: string[], getItemID: (item: TResource) => string): string[] { + const idToIndex = buildIDToIndexMap(sortedItems, getItemID); + + const hasStart = pageA.at(0) === CONST.PAGINATION_START_ID || pageB.at(0) === CONST.PAGINATION_START_ID; + const hasEnd = pageA.at(-1) === CONST.PAGINATION_END_ID || pageB.at(-1) === CONST.PAGINATION_END_ID; + + const uniqueIDs = new Set(); + for (const id of [...pageA, ...pageB]) { + if (isPaginationMarker(id)) { + continue; + } + if (!idToIndex.has(id)) { + continue; + } + uniqueIDs.add(id); + } + + const sortedIDs = [...uniqueIDs].sort((a, b) => (idToIndex.get(a) ?? 0) - (idToIndex.get(b) ?? 0)); + if (hasStart) { + sortedIDs.unshift(CONST.PAGINATION_START_ID); + } + if (hasEnd) { + sortedIDs.push(CONST.PAGINATION_END_ID); + } + return sortedIDs; +} + +/** + * Merge pages only when they have clear ID-overlap evidence. + * + * This intentionally does NOT use index overlap between pages to infer continuity, because when we + * open a report in the middle of the chat (e.g. last-unread), the locally available action set may + * not contain the actions in the gap, making disjoint pages appear overlapping. + */ +function mergePagesByIDOverlap(sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string): Pages { + if (pages.length === 0) { + return []; + } + + const idToIndex = buildIDToIndexMap(sortedItems, getItemID); + const lastIndexInSortedItems = Math.max(0, sortedItems.length - 1); + + const pagesWithKeys: PageWithSortKey[] = []; + for (const page of pages) { + const indexes = getFirstAndLastIndexForPage(page, idToIndex, lastIndexInSortedItems); + if (!indexes) { + continue; + } + + // Remove any IDs we don't currently have so stored pages don't imply we have the gap contents. + const filteredIDs = page.filter((id) => isPaginationMarker(id) || idToIndex.has(id)); + pagesWithKeys.push({...indexes, ids: filteredIDs}); + } + + if (pagesWithKeys.length === 0) { + return []; + } + + pagesWithKeys.sort((a, b) => a.firstIndex - b.firstIndex); + + const result: string[][] = [pagesWithKeys.at(0)?.ids ?? []]; + for (let i = 1; i < pagesWithKeys.length; i++) { + const current = pagesWithKeys.at(i)?.ids ?? []; + const previous = result.at(-1) ?? []; + + const shouldMerge = current.at(0) === previous.at(-1) || pagesShareAnyNonMarkerID(previous, current); + if (!shouldMerge) { + result.push(current); + continue; + } + + result[result.length - 1] = mergeTwoPagesByUnionAndSort(sortedItems, previous, current, getItemID); + } + + return result; +} + /** * Returns the page of items that contains the item with the given ID, or the first page if null. * Also returns whether next / previous pages can be fetched. @@ -250,4 +403,6 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g }; } -export default {mergeAndSortContinuousPages, getContinuousChain}; +export {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain}; + +export default {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain}; From 80a7396acaf220ac080232d8a04f3f1f5ec4da10 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 23:37:38 +0100 Subject: [PATCH 181/216] fix: missing `shouldLinkToOldestUnreadReportAction` option in `usePaginatedReportActions` call --- src/pages/inbox/ReportScreen.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 07f237815932..e22bf9d61d42 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -216,7 +216,13 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const {reportActions: unfilteredReportActions, linkedAction, hasNewerActions, hasOlderActions, oldestUnreadReportAction} = usePaginatedReportActions(reportID, reportActionIDFromRoute); + const { + reportActions: unfilteredReportActions, + linkedAction, + hasNewerActions, + hasOlderActions, + oldestUnreadReportAction, + } = usePaginatedReportActions(reportID, reportActionIDFromRoute, {shouldLinkToOldestUnreadReportAction: true}); // wrapping in useMemo because this is array operation and can cause performance issues const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); const viewportOffsetTop = useViewportOffsetTop(); From 3f6c320a96463ade60e64cf5384d1f1b3517e3eb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 3 Apr 2026 23:48:54 +0100 Subject: [PATCH 182/216] fix: keep condition for initial request load --- src/libs/Middleware/Pagination.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index ed2990df6902..9a83308deed3 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -111,7 +111,7 @@ const Pagination: Middleware = (requestResponse, request) => { const newPage = sortedPageItems.map((item) => getItemID(item)); - const shouldMarkNoNewerActions = response.hasNewerActions === false || response.hasNewerActions === null; + const shouldMarkNoNewerActions = response.hasNewerActions === false || response.hasNewerActions === null || (type === 'initial' && !cursorID && response.hasNewerActions !== true); if (shouldMarkNoNewerActions) { newPage.unshift(CONST.PAGINATION_START_ID); } From d75a1afe2cff338461bd813034f7365370b0fc39 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 4 Apr 2026 00:20:12 +0100 Subject: [PATCH 183/216] fix: missing `shouldLinkToOldestUnreadReportAction` call --- src/pages/inbox/report/ReportActionsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 410384bebc89..35ad049fbae3 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -81,7 +81,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const reportActionID = route?.params?.reportActionID; - const {reportActions: unfilteredReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionID); + const {reportActions: unfilteredReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionID, {shouldLinkToOldestUnreadReportAction: true}); const allReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); const parentReportAction = useParentReportAction(report); From 9d1f19c6803d206b57fa16f28f7e6dd3bfcd93d9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 4 Apr 2026 12:28:31 +0100 Subject: [PATCH 184/216] fix: scrolling to bottom on new message snet --- src/hooks/usePaginatedReportActions.ts | 29 +++++-- src/libs/PaginationUtils.ts | 46 ++++++++-- src/libs/actions/Report/index.ts | 16 ++++ src/pages/inbox/report/ReportActionsList.tsx | 86 ++++++++++++++++++- src/pages/inbox/report/ReportActionsView.tsx | 14 ++- .../perf-test/ReportActionsList.perf-test.tsx | 4 + 6 files changed, 178 insertions(+), 17 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 5a3f78b2e8c6..39bdc573867d 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -12,13 +12,16 @@ import useReportIsArchived from './useReportIsArchived'; type UsePaginatedReportActionsOptions = { /** Whether to link to the oldest unread report action, if no other report action id is provided. */ shouldLinkToOldestUnreadReportAction?: boolean; + + /** When true, pagination anchors to the newest window only (ignores route and unread-derived anchors). */ + treatAsNoPaginationAnchor?: boolean; }; /** * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions. */ function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string, options?: UsePaginatedReportActionsOptions) { - const {shouldLinkToOldestUnreadReportAction = false} = options ?? {}; + const {shouldLinkToOldestUnreadReportAction = false, treatAsNoPaginationAnchor = false} = options ?? {}; const nonEmptyStringReportID = getNonEmptyStringOnyxID(reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nonEmptyStringReportID}`); @@ -45,6 +48,11 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const initialReportLastReadTime = useRef(report?.lastReadTime); const id = useMemo(() => { + /* eslint-disable react-hooks/refs -- initialReportLastReadTime snapshots lastRead at first render for stable unread deep-link anchor */ + if (treatAsNoPaginationAnchor) { + return undefined; + } + if (reportActionID) { return reportActionID; } @@ -53,14 +61,21 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return undefined; } - return sortedAllReportActions?.findLast((reportAction) => { - if (!initialReportLastReadTime.current) { - return false; + const initialLastReadTime = initialReportLastReadTime.current; + if (!initialLastReadTime || !sortedAllReportActions?.length) { + return undefined; + } + + for (let i = sortedAllReportActions.length - 1; i >= 0; i -= 1) { + const reportAction = sortedAllReportActions.at(i); + if (reportAction && reportAction.created > initialLastReadTime) { + return reportAction.reportActionID; } + } - return reportAction.created > initialReportLastReadTime.current; - })?.reportActionID; - }, [reportActionID, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); + return undefined; + /* eslint-enable react-hooks/refs */ + }, [treatAsNoPaginationAnchor, reportActionID, shouldLinkToOldestUnreadReportAction, sortedAllReportActions]); const { data: reportActions, diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index 32689854542c..972c73e2097f 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -322,6 +322,40 @@ function mergePagesByIDOverlap(sortedItems: TResource[], pages: Pages return result; } +/** + * Picks the chronologically newest page: prefers the slice marked with PAGINATION_START_ID (synced to present), + * otherwise the page whose span starts at the smallest index in descending-sorted items. + */ +function selectNewestPageWithIndex(pagesWithIndexes: PageWithIndex[]): PageWithIndex | undefined { + if (pagesWithIndexes.length === 0) { + return undefined; + } + + const pageWithStartMarker = pagesWithIndexes.find((pageWithIndex) => pageWithIndex.firstID === CONST.PAGINATION_START_ID); + if (pageWithStartMarker) { + return pageWithStartMarker; + } + + return pagesWithIndexes.reduce((newest, candidate) => (candidate.firstIndex < newest.firstIndex ? candidate : newest)); +} + +/** + * Collapses pagination to a single page row for the newest window. Used after jumping to the live tail of a chat. + */ +function prunePagesToNewestWindow(sortedItems: TResource[], pages: Pages, getID: (item: TResource) => string): Pages { + if (pages.length <= 1) { + return pages; + } + + const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); + const newestPage = selectNewestPageWithIndex(pagesWithIndexes); + if (!newestPage) { + return pages; + } + + return [newestPage.ids]; +} + /** * Returns the page of items that contains the item with the given ID, or the first page if null. * Also returns whether next / previous pages can be fetched. @@ -384,10 +418,10 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g page = linkedPage; } } else { - // If we did not find an item with the resource id, we want to link to the first page - const pageAtIndex0 = pagesWithIndexes.at(0); - if (pageAtIndex0) { - page = pageAtIndex0; + // If we did not find an item with the resource id, show the newest page (not rely on arbitrary Onyx page order). + const newestPage = selectNewestPageWithIndex(pagesWithIndexes); + if (newestPage) { + page = newestPage; } } @@ -403,6 +437,6 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g }; } -export {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain}; +export {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain, prunePagesToNewestWindow, selectNewestPageWithIndex}; -export default {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain}; +export default {mergeAndSortContinuousPages, mergePagesByIDOverlap, getContinuousChain, prunePagesToNewestWindow, selectNewestPageWithIndex}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index da6c106c15aa..52d09bb3d936 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -86,6 +86,7 @@ import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import LocalNotification from '@libs/Notification/LocalNotification'; import {rand64} from '@libs/NumberUtils'; import capturePageHTML from '@libs/PageHTMLCapture'; +import PaginationUtils from '@libs/PaginationUtils'; import Parser from '@libs/Parser'; import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -206,6 +207,7 @@ import type { NewGroupChatDraft, Onboarding, OnboardingPurpose, + Pages, PersonalDetailsList, Policy, PolicyEmployee, @@ -1652,6 +1654,18 @@ function openReport(params: OpenReportActionParams) { } } +/** + * Drops stale mid-chat pagination rows after the list shows the live tail and scroll completed. + */ +function pruneReportActionPagesToNewestWindow(reportID: string | undefined, sortedReportActions: ReportAction[], pages: Pages | undefined) { + if (!reportID || !pages?.length || pages.length <= 1) { + return; + } + + const pruned = PaginationUtils.prunePagesToNewestWindow(sortedReportActions, pages, (action) => action.reportActionID); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportID}`, pruned); +} + /** * Create a group chat report. Simplified version specifically for group chats without unnecessary logic. * @@ -7324,6 +7338,7 @@ export { clearCreateChatError, notifyNewAction, openReport, + openReportAtLatestReportActions, openRoomMembersPage, readNewestAction, removeFromGroupChat, @@ -7381,6 +7396,7 @@ export { optimisticReportLastData, setOptimisticTransactionThread, prepareOnyxDataForCleanUpOptimisticParticipants, + pruneReportActionPagesToNewestWindow, getGuidedSetupDataForOpenReport, getReportChannelName, }; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index b9a19492fd4d..20ce9aff2252 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -24,6 +24,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {openReport, pruneReportActionPagesToNewestWindow, readNewestAction, subscribeToNewActionEvent} from '@libs/actions/Report'; import {isSafari} from '@libs/Browser'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; @@ -64,7 +65,6 @@ import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; 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'; import ROUTES from '@src/ROUTES'; @@ -113,6 +113,17 @@ type ReportActionsListProps = { /** Whether the report has newer actions to load */ hasNewerActions: boolean; + /** Full sorted report actions for collapsing stale pagination after a live-tail jump */ + sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; + + /** Current report action pages from Onyx */ + reportActionPages: OnyxTypes.Pages | undefined; + + /** When true, the paginated hook ignores deep-link / unread anchors */ + treatAsNoPaginationAnchor: boolean; + + setTreatAsNoPaginationAnchor: (value: boolean) => void; + /** Whether the composer is in full size */ isComposerFullSize?: boolean; @@ -169,6 +180,10 @@ function ReportActionsList({ loadNewerChats, loadOlderChats, hasNewerActions, + sortedAllReportActionsForPagination, + reportActionPages, + treatAsNoPaginationAnchor, + setTreatAsNoPaginationAnchor, onLayout, isComposerFullSize, listID, @@ -215,6 +230,7 @@ function ReportActionsList({ const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); const [actionIdToHighlight, setActionIdToHighlight] = useState(''); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`); + const prevIsLoadingInitialReportActions = usePrevious(reportMetadata?.isLoadingInitialReportActions); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const backTo = route?.params?.backTo as string; @@ -351,6 +367,15 @@ function ReportActionsList({ const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); const hasNewestReportActionRef = useRef(hasNewestReportAction); hasNewestReportActionRef.current = hasNewestReportAction; + const hasNewerActionsRef = useRef(hasNewerActions); + hasNewerActionsRef.current = hasNewerActions; + const linkedReportActionIDRef = useRef(linkedReportActionID); + linkedReportActionIDRef.current = linkedReportActionID; + const sortedAllReportActionsForPaginationRef = useRef(sortedAllReportActionsForPagination); + const reportActionPagesRef = useRef(reportActionPages); + sortedAllReportActionsForPaginationRef.current = sortedAllReportActionsForPagination; + reportActionPagesRef.current = reportActionPages; + const liveTailJumpRef = useRef<{stage: 'idle' | 'open_report' | 'await_scroll' | 'await_prune'}>({stage: 'idle'}); const sortedVisibleReportActionsRef = useRef(sortedVisibleReportActions); const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ @@ -562,6 +587,22 @@ function ReportActionsList({ }); return; } + + const shouldJumpToLiveTail = + !isOffline && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (hasNewerActionsRef.current || !!linkedReportActionIDRef.current); + + if (shouldJumpToLiveTail) { + if (liveTailJumpRef.current.stage === 'idle') { + liveTailJumpRef.current = {stage: 'open_report'}; + openReport({ + reportID: report.reportID, + introSelected, + betas, + }); + } + return; + } + const index = sortedVisibleReportActionsRef.current.findIndex((item) => keyExtractor(item) === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { if (index > 0) { @@ -583,9 +624,42 @@ function ReportActionsList({ setIsScrollToBottomEnabled(true); }); }, - [report.reportID, reportScrollManager, setIsFloatingMessageCounterVisible], + [betas, introSelected, isOffline, report.reportID, reportScrollManager, setIsFloatingMessageCounterVisible], ); + useEffect(() => { + liveTailJumpRef.current = {stage: 'idle'}; + }, [report.reportID]); + + useEffect(() => { + if (liveTailJumpRef.current.stage !== 'open_report') { + return; + } + + const finishedInitialLoad = prevIsLoadingInitialReportActions === true && reportMetadata?.isLoadingInitialReportActions === false; + + if (!finishedInitialLoad) { + return; + } + + setTreatAsNoPaginationAnchor(true); + Navigation.setParams({reportActionID: ''}); + liveTailJumpRef.current = {stage: 'await_scroll'}; + }, [prevIsLoadingInitialReportActions, reportMetadata?.isLoadingInitialReportActions, setTreatAsNoPaginationAnchor]); + + useEffect(() => { + if (liveTailJumpRef.current.stage !== 'await_scroll') { + return; + } + if (!hasNewestReportAction) { + return; + } + + liveTailJumpRef.current = {stage: 'await_prune'}; + setIsFloatingMessageCounterVisible(false); + setIsScrollToBottomEnabled(true); + }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible]); + // Clear the highlighted report action after scrolling and highlighting useEffect(() => { if (actionIdToHighlight === '') { @@ -816,6 +890,12 @@ function ReportActionsList({ if (isScrollToBottomEnabled) { reportScrollManager.scrollToBottom(); setIsScrollToBottomEnabled(false); + if (liveTailJumpRef.current.stage === 'await_prune') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- pruneReportActionPagesToNewestWindow is typed; rule loses inference via Report actions barrel + pruneReportActionPagesToNewestWindow(report.reportID, sortedAllReportActionsForPaginationRef.current, reportActionPagesRef.current); + setTreatAsNoPaginationAnchor(false); + liveTailJumpRef.current = {stage: 'idle'}; + } } if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { requestAnimationFrame(() => { @@ -823,7 +903,7 @@ function ReportActionsList({ }); } }, - [isOffline, isScrollToBottomEnabled, onLayout, reportScrollManager, hasCreatedActionAdded, shouldScrollToEndAfterLayout], + [isOffline, isScrollToBottomEnabled, onLayout, report.reportID, reportScrollManager, hasCreatedActionAdded, shouldScrollToEndAfterLayout, setTreatAsNoPaginationAnchor], ); const retryLoadNewerChatsError = useCallback(() => { diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 80de261e1fda..9dcf2ed4e823 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -24,6 +24,7 @@ import {getReportPreviewAction} from '@libs/actions/IOU'; import {updateLoadingInitialReportAction} from '@libs/actions/Report'; import DateUtils from '@libs/DateUtils'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; @@ -81,13 +82,20 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const reportActionID = route?.params?.reportActionID; + const [treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor] = useState(false); + const nonEmptyReportIDForPages = getNonEmptyStringOnyxID(reportID); + const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${nonEmptyReportIDForPages}`); const { reportActions: unfilteredReportActions, hasNewerActions, hasOlderActions, + sortedAllReportActions, oldestUnreadReportAction, linkedAction, - } = usePaginatedReportActions(reportID, reportActionID, {shouldLinkToOldestUnreadReportAction: true}); + } = usePaginatedReportActions(reportID, reportActionID, { + shouldLinkToOldestUnreadReportAction: true, + treatAsNoPaginationAnchor, + }); const allReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); const parentReportAction = useParentReportAction(report); @@ -440,6 +448,10 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { loadOlderChats={loadOlderChats} loadNewerChats={loadNewerChats} hasNewerActions={hasNewerActions} + sortedAllReportActionsForPagination={sortedAllReportActions ?? []} + reportActionPages={reportActionPages} + treatAsNoPaginationAnchor={treatAsNoPaginationAnchor} + setTreatAsNoPaginationAnchor={setTreatAsNoPaginationAnchor} listID={listID} shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScroll} hasCreatedActionAdded={shouldAddCreatedAction} diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 20712949dd6c..6e97bbcfd5e3 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -114,6 +114,10 @@ function ReportActionsListWrapper() { loadOlderChats={mockLoadChats} loadNewerChats={mockLoadChats} hasNewerActions={false} + sortedAllReportActionsForPagination={reportActions} + reportActionPages={undefined} + treatAsNoPaginationAnchor={false} + setTreatAsNoPaginationAnchor={() => {}} transactionThreadReport={report} /> From 0d9fc51e93a8b3492d4edfd1c63ba7f4ec073287 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 14 Apr 2026 14:03:30 +0100 Subject: [PATCH 185/216] fix: invalid import --- src/libs/actions/Report/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index f04ee8640fa9..8819f95f34ea 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -7345,7 +7345,6 @@ export { clearCreateChatError, notifyNewAction, openReport, - openReportAtLatestReportActions, openRoomMembersPage, readNewestAction, removeFromGroupChat, From 120aaf5ae02b14bc758f1276902b30684fcc2e27 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 14 Apr 2026 17:57:40 +0100 Subject: [PATCH 186/216] fix: remove unused backend response param --- tests/ui/PaginationTest.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index a30462358e3c..3be52413defa 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -150,7 +150,6 @@ function mockOpenReport(messageCount: number, initialID: string) { hasOlderActions: !comments['1'], // When comment-linking (reportActionID present), there may be newer actions beyond the cursor. hasNewerActions: !!reportActionID, - oldestUnreadReportActionID: null, }; }); } From c10884c288371b43380cd1434592f9bd36210b55 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Apr 2026 17:02:30 +0100 Subject: [PATCH 187/216] fix: oldest unread report action not loading when loading with no cache --- src/pages/inbox/report/ReportActionsView.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 9dcf2ed4e823..6b124e7821ae 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -184,9 +184,11 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { updateLoadingInitialReportAction(report?.reportID ?? reportID); }, [isOffline, report?.reportID, reportID, reportActionID]); + const previousOldestUnreadReportActionID = usePrevious(oldestUnreadReportAction?.reportActionID); + // Change the list ID only for comment linking to get the positioning right const listID = useMemo(() => { - if (!reportActionID && !prevReportActionID) { + if (!reportActionID && !prevReportActionID && oldestUnreadReportAction?.reportActionID === previousOldestUnreadReportActionID) { // Keep the old list ID since we're not in the Comment Linking flow return listOldID; } @@ -195,7 +197,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { return newID; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [route, reportActionID]); + }, [route, reportActionIDFromRoute, oldestUnreadReportAction?.reportActionID, previousOldestUnreadReportActionID]); // When we are offline before opening an IOU/Expense report, // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. From c0f09e25ad9cef0e4e6c51067e25f751cb8e7cee Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Apr 2026 17:05:16 +0100 Subject: [PATCH 188/216] refactor: improve `ReportScreen` components navigation props --- src/pages/inbox/ReportActionsList.tsx | 6 ++-- src/pages/inbox/report/ReportActionsView.tsx | 31 ++++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/pages/inbox/ReportActionsList.tsx b/src/pages/inbox/ReportActionsList.tsx index 9746b422f88c..ac7a0b1452bc 100644 --- a/src/pages/inbox/ReportActionsList.tsx +++ b/src/pages/inbox/ReportActionsList.tsx @@ -11,6 +11,7 @@ import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitFo import {isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ReportActionsView from './report/ReportActionsView'; +import type {ReportScreenNavigationProps} from './types'; const defaultReportMetadata = { hasOnceLoadedReportActions: false, @@ -28,9 +29,8 @@ const defaultReportMetadata = { * conditions need — heavy data derivation is pushed into each child. */ function ReportActionsList() { - const route = useRoute(); - const routeParams = route.params as {reportID?: string} | undefined; - const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID); + const route = useRoute(); + const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); const {isOffline} = useNetwork(); diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 6b124e7821ae..7c60b6fb6e9d 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -26,8 +26,6 @@ import DateUtils from '@libs/DateUtils'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; import { getCombinedReportActions, @@ -52,9 +50,9 @@ import { isUnread, } from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; +import type {ReportScreenNavigationProps} from '@pages/inbox/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -72,16 +70,17 @@ type ReportActionsViewProps = { let listOldID = Math.round(Math.random() * 100); function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { + const route = useRoute(); + const reportActionIDFromRoute = route?.params?.reportActionID; + useCopySelectionHelper(); const {translate} = useLocalize(); usePendingConciergeResponse(reportID); - const route = useRoute>(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const reportActionID = route?.params?.reportActionID; const [treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor] = useState(false); const nonEmptyReportIDForPages = getNonEmptyStringOnyxID(reportID); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${nonEmptyReportIDForPages}`); @@ -92,7 +91,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { sortedAllReportActions, oldestUnreadReportAction, linkedAction, - } = usePaginatedReportActions(reportID, reportActionID, { + } = usePaginatedReportActions(reportID, reportActionIDFromRoute, { shouldLinkToOldestUnreadReportAction: true, treatAsNoPaginationAnchor, }); @@ -156,7 +155,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const prevTransactionThreadReport = usePrevious(transactionThreadReport); - const prevReportActionID = usePrevious(reportActionID); + const prevReportActionID = usePrevious(reportActionIDFromRoute); const reportPreviewAction = useMemo(() => getReportPreviewAction(report?.chatReportID, report?.reportID), [report?.chatReportID, report?.reportID]); const didLayout = useRef(false); @@ -178,17 +177,17 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { useEffect(() => { // When we linked to message - we do not need to wait for initial actions - they already exists - if (!reportActionID || !isOffline) { + if (!reportActionIDFromRoute || !isOffline) { return; } updateLoadingInitialReportAction(report?.reportID ?? reportID); - }, [isOffline, report?.reportID, reportID, reportActionID]); + }, [isOffline, report?.reportID, reportID, reportActionIDFromRoute]); const previousOldestUnreadReportActionID = usePrevious(oldestUnreadReportAction?.reportActionID); // Change the list ID only for comment linking to get the positioning right const listID = useMemo(() => { - if (!reportActionID && !prevReportActionID && oldestUnreadReportAction?.reportActionID === previousOldestUnreadReportActionID) { + if (!reportActionIDFromRoute && !prevReportActionID && oldestUnreadReportAction?.reportActionID === previousOldestUnreadReportActionID) { // Keep the old list ID since we're not in the Comment Linking flow return listOldID; } @@ -355,12 +354,12 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { }; // Check if the first report action in the list is the one we're currently linked to - const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; + const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionIDFromRoute; useEffect(() => { let timerID: NodeJS.Timeout; - if (!isTheFirstReportActionIsLinked && reportActionID) { + if (!isTheFirstReportActionIsLinked && reportActionIDFromRoute) { setNavigatingToLinkedMessage(true); // After navigating to the linked reportAction, apply this to correctly set // `autoscrollToTopThreshold` prop when linking to a specific reportAction. @@ -379,7 +378,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { } clearTimeout(timerID); }; - }, [isTheFirstReportActionIsLinked, reportActionID]); + }, [isTheFirstReportActionIsLinked, reportActionIDFromRoute]); // Show skeleton while loading initial report actions when data is incomplete/missing and online const shouldShowSkeletonForInitialLoad = isLoadingInitialReportActions && (isReportDataIncomplete || isMissingReportActions) && !isOffline; @@ -402,11 +401,11 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { // When we first open a report with a linked report action, // we need to wait for the results from the OpenReport api call, // if the linked report action is not stored in Onyx. - const isLinkedMessagePageLoadingInitially = !!reportActionID && !linkedAction; + const isLinkedMessagePageLoadingInitially = !!reportActionIDFromRoute && !linkedAction; // Same for unread messages, we need to wait for the results from the OpenReport api call, // if the oldest unread report action is not stored in Onyx. - const isUnreadMessagePageLoadingInitially = !reportActionID && isReportUnreadInitially.current && !oldestUnreadReportAction; + const isUnreadMessagePageLoadingInitially = !reportActionIDFromRoute && isReportUnreadInitially.current && !oldestUnreadReportAction; const shouldWaitForOpenReportResultInitially = isLinkedMessagePageLoadingInitially || isUnreadMessagePageLoadingInitially; @@ -436,7 +435,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { } // AutoScroll is disabled when we do linking to a specific reportAction - const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); + const shouldEnableAutoScroll = (hasNewestReportAction && (!reportActionIDFromRoute || !isNavigatingToLinkedMessage)) || (transactionThreadReport && !prevTransactionThreadReport); return ( <> Date: Wed, 15 Apr 2026 17:12:26 +0100 Subject: [PATCH 189/216] fix: use `oldestUnreadReportActionID` for `unreadMarkerReportActionID` --- src/hooks/usePaginatedReportActions.ts | 7 +-- src/pages/inbox/report/ReportActionsList.tsx | 52 +++++++++++++++----- src/pages/inbox/report/ReportActionsView.tsx | 3 ++ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 39bdc573867d..4f3d44cf85de 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -92,17 +92,18 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem?.item, reportActionID]); - const oldestUnreadReportAction = useMemo(() => { + const [oldestUnreadReportAction, oldestUnreadReportActionIndex] = useMemo(() => { if (shouldLinkToOldestUnreadReportAction && resourceItem && !reportActionID) { - return resourceItem.item; + return [resourceItem.item, resourceItem.index]; } - return undefined; + return [undefined, -1]; }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID]); return { reportActions, linkedAction, oldestUnreadReportAction, + oldestUnreadReportActionIndex, sortedAllReportActions, hasOlderActions: hasNextPage, hasNewerActions: hasPreviousPage, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 20ce9aff2252..d0a41e68f92d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -113,6 +113,12 @@ type ReportActionsListProps = { /** Whether the report has newer actions to load */ hasNewerActions: boolean; + /** The oldest unread report action */ + oldestUnreadReportAction: OnyxEntry | undefined; + + /** The index of the oldest unread report action */ + oldestUnreadReportActionIndex: number; + /** Full sorted report actions for collapsing stale pagination after a live-tail jump */ sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; @@ -180,6 +186,8 @@ function ReportActionsList({ loadNewerChats, loadOlderChats, hasNewerActions, + oldestUnreadReportAction, + oldestUnreadReportActionIndex, sortedAllReportActionsForPagination, reportActionPages, treatAsNoPaginationAnchor, @@ -303,21 +311,41 @@ function ReportActionsList({ return receivedOfflineMessages.at(-1); }, [getLocalDateFromDatetime, isOffline, lastOfflineAt, lastOnlineAt, sortedReportActions]); + const oldestUnreadReportActionMarker = useMemo<[string, number] | undefined>( + () => (!!oldestUnreadReportAction && !!oldestUnreadReportActionIndex ? [oldestUnreadReportAction.reportActionID, oldestUnreadReportActionIndex] : undefined), + [oldestUnreadReportAction, oldestUnreadReportActionIndex], + ); + /** * The reportActionID the unread marker should display above */ - const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = getUnreadMarkerReportAction({ - visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, - prevSortedVisibleReportActionsObjects, - unreadMarkerTime, - scrollingVerticalOffset: scrollOffsetRef.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, - isOffline, - isReversed: false, - isAnonymousUser, - }); + const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = useMemo( + () => + oldestUnreadReportActionMarker ?? + getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, + earliestReceivedOfflineMessageIndex, + currentUserAccountID, + prevSortedVisibleReportActionsObjects, + unreadMarkerTime, + scrollingVerticalOffset: scrollOffsetRef.current, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + isOffline, + isReversed: false, + isAnonymousUser, + }), + [ + currentUserAccountID, + earliestReceivedOfflineMessageIndex, + isAnonymousUser, + isOffline, + oldestUnreadReportActionMarker, + prevSortedVisibleReportActionsObjects, + scrollOffsetRef, + sortedVisibleReportActions, + unreadMarkerTime, + ], + ); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; /** diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 7c60b6fb6e9d..2fdc009a2bec 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -90,6 +90,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { hasOlderActions, sortedAllReportActions, oldestUnreadReportAction, + oldestUnreadReportActionIndex, linkedAction, } = usePaginatedReportActions(reportID, reportActionIDFromRoute, { shouldLinkToOldestUnreadReportAction: true, @@ -449,6 +450,8 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { loadOlderChats={loadOlderChats} loadNewerChats={loadNewerChats} hasNewerActions={hasNewerActions} + oldestUnreadReportAction={oldestUnreadReportAction} + oldestUnreadReportActionIndex={oldestUnreadReportActionIndex} sortedAllReportActionsForPagination={sortedAllReportActions ?? []} reportActionPages={reportActionPages} treatAsNoPaginationAnchor={treatAsNoPaginationAnchor} From fcbe378ef7fafa503fdbd3dc77a6046b66775c09 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Apr 2026 17:18:07 +0100 Subject: [PATCH 190/216] fix: make `oldestUnreadReportAction` props optional --- 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 d0a41e68f92d..7eb6ba7b279f 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -114,10 +114,10 @@ type ReportActionsListProps = { hasNewerActions: boolean; /** The oldest unread report action */ - oldestUnreadReportAction: OnyxEntry | undefined; + oldestUnreadReportAction?: OnyxEntry | undefined; /** The index of the oldest unread report action */ - oldestUnreadReportActionIndex: number; + oldestUnreadReportActionIndex?: number; /** Full sorted report actions for collapsing stale pagination after a live-tail jump */ sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; @@ -312,7 +312,7 @@ function ReportActionsList({ }, [getLocalDateFromDatetime, isOffline, lastOfflineAt, lastOnlineAt, sortedReportActions]); const oldestUnreadReportActionMarker = useMemo<[string, number] | undefined>( - () => (!!oldestUnreadReportAction && !!oldestUnreadReportActionIndex ? [oldestUnreadReportAction.reportActionID, oldestUnreadReportActionIndex] : undefined), + () => (!!oldestUnreadReportAction && oldestUnreadReportActionIndex !== undefined ? [oldestUnreadReportAction.reportActionID, oldestUnreadReportActionIndex] : undefined), [oldestUnreadReportAction, oldestUnreadReportActionIndex], ); From 4d831eb95b427ad2a916c3bbaeb1f5ec33fdb9cb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Apr 2026 17:24:38 +0100 Subject: [PATCH 191/216] fix: TS errors --- tests/ui/ReportActionsViewTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/ReportActionsViewTest.tsx b/tests/ui/ReportActionsViewTest.tsx index 8983c0705aaa..11163afce646 100644 --- a/tests/ui/ReportActionsViewTest.tsx +++ b/tests/ui/ReportActionsViewTest.tsx @@ -60,6 +60,7 @@ const defaultPaginatedReportActionsResult: ReturnType Date: Fri, 24 Apr 2026 11:32:44 +0100 Subject: [PATCH 192/216] fix: remove unused `InvertedFlatList` code --- .../CellRendererComponent.tsx | 30 -------- .../FlatList/InvertedFlatList/index.tsx | 73 ------------------- .../index.native.ts | 6 -- .../shouldRemoveClippedSubviews/index.ts | 1 - .../FlatList/InvertedFlatList/types.ts | 16 ---- 5 files changed, 126 deletions(-) delete mode 100644 src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx delete mode 100644 src/components/FlatList/InvertedFlatList/index.tsx delete mode 100644 src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts delete mode 100644 src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts delete mode 100644 src/components/FlatList/InvertedFlatList/types.ts diff --git a/src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx b/src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx deleted file mode 100644 index 8f1640a2305d..000000000000 --- a/src/components/FlatList/InvertedFlatList/CellRendererComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; -import {View} from 'react-native'; - -type CellRendererComponentProps = ViewProps & { - index: number; - style?: StyleProp; -}; - -function CellRendererComponent(props: CellRendererComponentProps) { - return ( - - ); -} - -export default CellRendererComponent; diff --git a/src/components/FlatList/InvertedFlatList/index.tsx b/src/components/FlatList/InvertedFlatList/index.tsx deleted file mode 100644 index 98bb99cd852e..000000000000 --- a/src/components/FlatList/InvertedFlatList/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, {useState} from 'react'; -import FlatList from '@components/FlatList/FlatList'; -import useFlatListScrollKey from '@components/FlatList/hooks/useFlatListScrollKey'; -import CellRendererComponent from './CellRendererComponent'; -import shouldRemoveClippedSubviews from './shouldRemoveClippedSubviews'; -import type {InvertedFlatListProps} from './types'; - -// Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 -function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { - if (item != null) { - if (typeof item === 'object' && 'key' in item) { - return item.key; - } - if (typeof item === 'object' && 'id' in item) { - return item.id; - } - } - return String(index); -} - -function InvertedFlatList({ - ref, - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - initialNumToRender, - onStartReached, - renderItem, - keyExtractor = defaultKeyExtractor, - onContentSizeChange, - onInitiallyLoaded, - ...restProps -}: InvertedFlatListProps) { - const [didInitialContentRender, setDidInitialContentRender] = useState(false); - const handleContentSizeChange = (contentWidth: number, contentHeight: number) => { - onContentSizeChange?.(contentWidth, contentHeight); - setDidInitialContentRender(true); - }; - - const {displayedData, maintainVisibleContentPosition, handleStartReached, handleRenderItem, listRef} = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - inverted: true, - onStartReached, - shouldEnableAutoScrollToTopThreshold, - renderItem, - initialNumToRender, - didInitialContentRender, - onInitiallyLoaded, - ref, - }); - - return ( - - // eslint-disable-next-line react/jsx-props-no-spreading - {...restProps} - ref={listRef} - maintainVisibleContentPosition={maintainVisibleContentPosition} - inverted - data={displayedData} - initialNumToRender={initialNumToRender} - renderItem={handleRenderItem} - keyExtractor={keyExtractor} - onStartReached={handleStartReached} - onContentSizeChange={handleContentSizeChange} - CellRendererComponent={CellRendererComponent} - removeClippedSubviews={shouldRemoveClippedSubviews} - /> - ); -} - -export default InvertedFlatList; diff --git a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts b/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts deleted file mode 100644 index 1da980d0e6cc..000000000000 --- a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * To achieve absolute positioning and handle overflows for list items, the property must be disabled - * for Android native builds. - * Source: https://reactnative.dev/docs/0.71/optimizing-flatlist-configuration#removeclippedsubviews - */ -export default false; diff --git a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts b/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts deleted file mode 100644 index f237ddf58ed4..000000000000 --- a/src/components/FlatList/InvertedFlatList/shouldRemoveClippedSubviews/index.ts +++ /dev/null @@ -1 +0,0 @@ -export default undefined; diff --git a/src/components/FlatList/InvertedFlatList/types.ts b/src/components/FlatList/InvertedFlatList/types.ts deleted file mode 100644 index 5d24b22c7ff7..000000000000 --- a/src/components/FlatList/InvertedFlatList/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type {ListRenderItem, FlatList as RNFlatList} from 'react-native'; -import type {CustomFlatListProps} from '@components/FlatList/FlatList/types'; - -type InvertedFlatListProps = Omit, 'data' | 'renderItem' | 'initialScrollIndex'> & { - shouldEnableAutoScrollToTopThreshold?: boolean; - data: T[]; - renderItem: ListRenderItem; - initialScrollKey?: string | null; - ref?: ForwardedRef; - - onInitiallyLoaded?: () => void; -}; - -// eslint-disable-next-line import/prefer-default-export -export type {InvertedFlatListProps}; From a83f2265b2c28b5cdd829bcf43f8dde59e169ea7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 11:35:02 +0100 Subject: [PATCH 193/216] fix: `RenderTaskQueue` import updated --- jest/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest/setup.ts b/jest/setup.ts index 24e9d026358a..05e5208261e0 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -12,7 +12,7 @@ import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import type Animated from 'react-native-reanimated'; import 'setimmediate'; import {TextDecoder, TextEncoder} from 'util'; -import type {RenderInfo} from '@components/FlatList/InvertedFlatList/RenderTaskQueue'; +import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; import '@src/polyfills/PromiseWithResolvers'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; From 47c5c852509bb25be7220e0249bd9f828c90f856 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 11:35:13 +0100 Subject: [PATCH 194/216] fix: remove `isListInitiallyLoaded` code in `ReportActionsList` --- src/pages/inbox/report/ReportActionsList.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index ffa65b4976fa..f63a2df215ca 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -465,11 +465,6 @@ function ReportActionsList({ return linkedReportActionID ?? unreadMarkerReportActionID; }, [linkedReportActionID, unreadMarkerReportActionID]); - const [isListInitiallyLoaded, setIsListInitiallyLoaded] = useState(false); - const handleListInitiallyLoaded = useCallback(() => { - setIsListInitiallyLoaded(true); - }, []); - const isReportUnread = useMemo( () => isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction)), [report, transactionThreadReport, isReportArchived, lastAction], @@ -478,10 +473,6 @@ function ReportActionsList({ // Mark the report as read when the user initially opens the report and there are unread messages const didMarkReportAsReadInitially = useRef(false); useEffect(() => { - if (!isListInitiallyLoaded) { - return; - } - if (!isReportUnread || didMarkReportAsReadInitially.current) { didMarkReportAsReadInitially.current = true; return; @@ -489,7 +480,7 @@ function ReportActionsList({ didMarkReportAsReadInitially.current = true; readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions); - }, [isListInitiallyLoaded, isReportUnread, report.reportID, reportMetadata?.hasOnceLoadedReportActions]); + }, [isReportUnread, report.reportID, reportMetadata?.hasOnceLoadedReportActions]); const handleReportChangeMarkAsRead = useCallback(() => { if (report.reportID !== prevReportID) { @@ -519,7 +510,7 @@ function ReportActionsList({ }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, reportMetadata?.hasOnceLoadedReportActions]); const handleAppVisibilityMarkAsRead = useCallback(() => { - if (report.reportID !== prevReportID || !isListInitiallyLoaded) { + if (report.reportID !== prevReportID) { return; } @@ -557,7 +548,7 @@ function ReportActionsList({ // We will mark the report as read in the above case which marks the LHN report item as read while showing the new message // marker for the chat messages received while the user wasn't focused on the report or on another browser tab for web. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFocused, isVisible, isListInitiallyLoaded, reportMetadata?.hasOnceLoadedReportActions]); + }, [isFocused, isVisible, reportMetadata?.hasOnceLoadedReportActions]); const prevHandleReportChangeMarkAsRead = useRef<() => void>(null); const prevHandleAppVisibilityMarkAsRead = useRef<() => void>(null); @@ -1052,7 +1043,6 @@ function ReportActionsList({ ListHeaderComponent={listHeaderComponent} ListFooterComponent={listFooterComponent} keyboardShouldPersistTaps="handled" - onInitiallyLoaded={handleListInitiallyLoaded} onLayout={onLayoutInner} onScroll={trackVerticalScrolling} onViewableItemsChanged={onViewableItemsChanged} From 52c994057cdbe6df755a11fb5ce537e2b1953c34 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 12:09:29 +0100 Subject: [PATCH 195/216] fix: use `initialScrollKey` instead of `linkedReportActionID` for more list behavior --- src/pages/inbox/report/ReportActionsList.tsx | 8 ++++---- .../report/getReportActionsListInitialNumToRender.ts | 6 +++--- tests/unit/getReportActionsListInitialNumToRenderTest.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 72de3c68b56b..b1f401e468a6 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -462,7 +462,7 @@ function ReportActionsList({ }, [report.reportID]); const initialScrollKey = useMemo(() => { - return linkedReportActionID ?? unreadMarkerReportActionID; + return linkedReportActionID ?? unreadMarkerReportActionID ?? undefined; }, [linkedReportActionID, unreadMarkerReportActionID]); const isReportUnread = useMemo( @@ -568,7 +568,7 @@ function ReportActionsList({ }, [handleReportChangeMarkAsRead, handleAppVisibilityMarkAsRead]); useEffect(() => { - if (!!linkedReportActionID || !!unreadMarkerReportActionID) { + if (initialScrollKey) { return; } @@ -780,7 +780,7 @@ function ReportActionsList({ const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); return getReportActionsListInitialNumToRender({ numToRender, - linkedReportActionID, + initialScrollKey, shouldScrollToEndAfterLayout, hasCreatedActionAdded, sortedVisibleReportActionsLength: sortedVisibleReportActions.length, @@ -791,7 +791,7 @@ function ReportActionsList({ styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, - linkedReportActionID, + initialScrollKey, shouldScrollToEndAfterLayout, hasCreatedActionAdded, sortedVisibleReportActions.length, diff --git a/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts b/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts index b524d688d0ca..af5901a359ee 100644 --- a/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts +++ b/src/pages/inbox/report/getReportActionsListInitialNumToRender.ts @@ -1,6 +1,6 @@ type GetReportActionsListInitialNumToRenderParams = { numToRender: number; - linkedReportActionID?: string; + initialScrollKey?: string; shouldScrollToEndAfterLayout: boolean; hasCreatedActionAdded?: boolean; sortedVisibleReportActionsLength: number; @@ -10,7 +10,7 @@ type GetReportActionsListInitialNumToRenderParams = { export default function getReportActionsListInitialNumToRender({ numToRender, - linkedReportActionID, + initialScrollKey, shouldScrollToEndAfterLayout, hasCreatedActionAdded, sortedVisibleReportActionsLength, @@ -21,7 +21,7 @@ export default function getReportActionsListInitialNumToRender({ return sortedVisibleReportActionsLength; } - if (linkedReportActionID) { + if (initialScrollKey) { return getInitialNumToRender(numToRender); } diff --git a/tests/unit/getReportActionsListInitialNumToRenderTest.ts b/tests/unit/getReportActionsListInitialNumToRenderTest.ts index 02afc164272c..95be72e1c8eb 100644 --- a/tests/unit/getReportActionsListInitialNumToRenderTest.ts +++ b/tests/unit/getReportActionsListInitialNumToRenderTest.ts @@ -18,7 +18,7 @@ describe('getReportActionsListInitialNumToRender', () => { it('returns the platform-adjusted value for linked report actions', () => { const result = getReportActionsListInitialNumToRender({ numToRender: 10, - linkedReportActionID: '123', + initialScrollKey: '123', shouldScrollToEndAfterLayout: false, hasCreatedActionAdded: true, sortedVisibleReportActionsLength: 500, From dfb0f49bf8a0fd8b00523c24d4df45daee504401 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 12:15:13 +0100 Subject: [PATCH 196/216] fix: more behavior based on unread marker --- src/pages/inbox/report/ReportActionsList.tsx | 55 ++++++++++---------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index b1f401e468a6..ad211cd00861 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -219,6 +219,7 @@ function ReportActionsList({ const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); + const isAnonymousUser = useIsAnonymousUser(); const isReportArchived = useReportIsArchived(report?.reportID); const [userWalletTierName] = useOnyx(ONYXKEYS.USER_WALLET, { selector: tierNameSelector, @@ -241,14 +242,6 @@ function ReportActionsList({ const backTo = route?.params?.backTo as string; const linkedReportActionID = route?.params?.reportActionID; - const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); - const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); - const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); - const topReportAction = sortedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); - const scrollEndTimerRef = useRef | undefined>(undefined); - const isAnonymousUser = useIsAnonymousUser(); - useEffect(() => { const unsubscribe = Visibility.onVisibilityChange(() => { setIsVisible(Visibility.isVisible()); @@ -288,19 +281,6 @@ function ReportActionsList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [report.reportID]); - // When lastReadTime transitions from empty to a real value (e.g., data hasn't - // loaded yet after sign-in), update the marker so it uses the fresh value - // instead of the empty string from initial mount. - useEffect(() => { - if (reportLastReadTime === '' || unreadMarkerTime !== '') { - return; - } - setUnreadMarkerTime(reportLastReadTime); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportLastReadTime]); - - const prevUnreadMarkerReportActionID = useRef(null); - /** * The index of the earliest message that was received while offline */ @@ -326,6 +306,7 @@ function ReportActionsList({ /** * The reportActionID the unread marker should display above */ + const prevUnreadMarkerReportActionID = useRef(null); const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = useMemo( () => oldestUnreadReportActionMarker ?? @@ -355,6 +336,28 @@ 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 shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); + const topReportAction = sortedVisibleReportActions.at(-1); + const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !initialScrollKey); + const scrollEndTimerRef = useRef | undefined>(undefined); + + // When lastReadTime transitions from empty to a real value (e.g., data hasn't + // loaded yet after sign-in), update the marker so it uses the fresh value + // instead of the empty string from initial mount. + useEffect(() => { + if (reportLastReadTime === '' || unreadMarkerTime !== '') { + return; + } + setUnreadMarkerTime(reportLastReadTime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportLastReadTime]); + /** * Subscribe to read/unread events and update our unreadMarkerTime */ @@ -461,10 +464,6 @@ function ReportActionsList({ prevReportID = report.reportID; }, [report.reportID]); - const initialScrollKey = useMemo(() => { - return linkedReportActionID ?? unreadMarkerReportActionID ?? undefined; - }, [linkedReportActionID, unreadMarkerReportActionID]); - const isReportUnread = useMemo( () => isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction)), [report, transactionThreadReport, isReportArchived, lastAction], @@ -622,7 +621,9 @@ function ReportActionsList({ } const shouldJumpToLiveTail = - !isOffline && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (hasNewerActionsRef.current || !!linkedReportActionIDRef.current); + !isOffline && + action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && + (hasNewerActionsRef.current || !!linkedReportActionIDRef.current || unreadMarkerReportActionID); if (shouldJumpToLiveTail) { if (liveTailJumpRef.current.stage === 'idle') { @@ -660,7 +661,7 @@ function ReportActionsList({ setIsScrollToBottomEnabled(true); }); }, - [betas, introSelected, isOffline, report.reportID, reportScrollManager, setIsFloatingMessageCounterVisible], + [betas, introSelected, isOffline, report.reportID, reportScrollManager, setIsFloatingMessageCounterVisible, unreadMarkerReportActionID], ); useEffect(() => { From 3c642b1a28a516b2ac4f24e8b175383d33019410 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 12:26:27 +0100 Subject: [PATCH 197/216] fix: `unreadMarkerReportActionID` not updating when `lastReadTime` updates --- src/pages/inbox/report/ReportActionsList.tsx | 54 +++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index ad211cd00861..6c46d1638ab0 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -307,33 +307,39 @@ function ReportActionsList({ * The reportActionID the unread marker should display above */ const prevUnreadMarkerReportActionID = useRef(null); - const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = useMemo( - () => - oldestUnreadReportActionMarker ?? - getUnreadMarkerReportAction({ - visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, - prevSortedVisibleReportActionsObjects, - unreadMarkerTime, - scrollingVerticalOffset: scrollOffsetRef.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, - isOffline, - isReversed: false, - isAnonymousUser, - }), - [ - currentUserAccountID, + const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = useMemo(() => { + const scanned = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, earliestReceivedOfflineMessageIndex, - isAnonymousUser, - isOffline, - oldestUnreadReportActionMarker, + currentUserAccountID, prevSortedVisibleReportActionsObjects, - scrollOffsetRef, - sortedVisibleReportActions, unreadMarkerTime, - ], - ); + scrollingVerticalOffset: scrollOffsetRef.current, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + isOffline, + isReversed: false, + isAnonymousUser, + }); + if (oldestUnreadReportActionMarker) { + const [oldestAnchorActionID] = oldestUnreadReportActionMarker; + // Pagination is anchored to the oldest unread on first open; that anchor does not change when the user + // marks read or unread, or when messages are deleted. Prefer the scan when it does not match that stale id. + if (scanned[0] !== null && scanned[0] !== oldestAnchorActionID) { + return scanned; + } + } + return oldestUnreadReportActionMarker ?? scanned; + }, [ + currentUserAccountID, + earliestReceivedOfflineMessageIndex, + isAnonymousUser, + isOffline, + oldestUnreadReportActionMarker, + prevSortedVisibleReportActionsObjects, + scrollOffsetRef, + sortedVisibleReportActions, + unreadMarkerTime, + ]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; const initialScrollKey = useMemo(() => { From e392a5718cd6e46d13ef8540ab3505f802981710 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 15:07:51 +0100 Subject: [PATCH 198/216] refactor: remove redundant in-place mutation of report actions pages from Onyx --- src/libs/Middleware/Pagination.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index 9a83308deed3..a00962954c5a 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -139,12 +139,6 @@ const Pagination: Middleware = (requestResponse, request) => { const mergeAndSortContinuousPagesTyped: (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages = mergeAndSortContinuousPages as unknown as (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages; - // When loading the first page of data, make sure to remove the start maker if the backend returns - // that there is new data. - const firstPage = existingPages.at(0); - if (type === 'initial' && !cursorID && firstPage?.at(0) === CONST.PAGINATION_START_ID && response.hasNewerActions === true) { - firstPage.shift(); - } const isMiddleInitialSlice = type === 'initial' && !cursorID && response.hasNewerActions === true && response.hasOlderActions === true; // Only strip PAGINATION_START_ID from cached pages when the server explicitly confirms newer actions exist. From 997d5fc442dd92ecd8490206e06ba0639e86fc3e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 15:16:19 +0100 Subject: [PATCH 199/216] refactor: remove oldestUnreadReportActionIndex from report actions handling --- src/hooks/usePaginatedReportActions.ts | 7 +++---- src/pages/inbox/report/ReportActionsList.tsx | 19 +++++++++++-------- src/pages/inbox/report/ReportActionsView.tsx | 2 -- tests/ui/ReportActionsViewTest.tsx | 1 - 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 4f3d44cf85de..39bdc573867d 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -92,18 +92,17 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem?.item, reportActionID]); - const [oldestUnreadReportAction, oldestUnreadReportActionIndex] = useMemo(() => { + const oldestUnreadReportAction = useMemo(() => { if (shouldLinkToOldestUnreadReportAction && resourceItem && !reportActionID) { - return [resourceItem.item, resourceItem.index]; + return resourceItem.item; } - return [undefined, -1]; + return undefined; }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID]); return { reportActions, linkedAction, oldestUnreadReportAction, - oldestUnreadReportActionIndex, sortedAllReportActions, hasOlderActions: hasNextPage, hasNewerActions: hasPreviousPage, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 6c46d1638ab0..a937b7faafc2 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -118,9 +118,6 @@ type ReportActionsListProps = { /** The oldest unread report action */ oldestUnreadReportAction?: OnyxEntry | undefined; - /** The index of the oldest unread report action */ - oldestUnreadReportActionIndex?: number; - /** Full sorted report actions for collapsing stale pagination after a live-tail jump */ sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; @@ -184,7 +181,6 @@ function ReportActionsList({ loadOlderChats, hasNewerActions, oldestUnreadReportAction, - oldestUnreadReportActionIndex, sortedAllReportActionsForPagination, reportActionPages, treatAsNoPaginationAnchor, @@ -298,10 +294,17 @@ function ReportActionsList({ return receivedOfflineMessages.at(-1); }, [getLocalDateFromDatetime, isOffline, lastOfflineAt, lastOnlineAt, sortedReportActions]); - const oldestUnreadReportActionMarker = useMemo<[string, number] | undefined>( - () => (!!oldestUnreadReportAction && oldestUnreadReportActionIndex !== undefined ? [oldestUnreadReportAction.reportActionID, oldestUnreadReportActionIndex] : undefined), - [oldestUnreadReportAction, oldestUnreadReportActionIndex], - ); + // Index must be in the same domain as FlatList `data` (sortedVisibleReportActions), not the paginated full chain. + const oldestUnreadReportActionMarker = useMemo<[string, number] | undefined>(() => { + if (!oldestUnreadReportAction) { + return undefined; + } + const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); + if (visibleIndex < 0) { + return undefined; + } + return [oldestUnreadReportAction.reportActionID, visibleIndex]; + }, [oldestUnreadReportAction, sortedVisibleReportActions]); /** * The reportActionID the unread marker should display above diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index b449da22b8fe..80b3c0fd7baa 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -89,7 +89,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { hasOlderActions, sortedAllReportActions, oldestUnreadReportAction, - oldestUnreadReportActionIndex, linkedAction, } = usePaginatedReportActions(reportID, reportActionIDFromRoute, { shouldLinkToOldestUnreadReportAction: true, @@ -413,7 +412,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { loadNewerChats={loadNewerChats} hasNewerActions={hasNewerActions} oldestUnreadReportAction={oldestUnreadReportAction} - oldestUnreadReportActionIndex={oldestUnreadReportActionIndex} sortedAllReportActionsForPagination={sortedAllReportActions ?? []} reportActionPages={reportActionPages} treatAsNoPaginationAnchor={treatAsNoPaginationAnchor} diff --git a/tests/ui/ReportActionsViewTest.tsx b/tests/ui/ReportActionsViewTest.tsx index 11163afce646..8983c0705aaa 100644 --- a/tests/ui/ReportActionsViewTest.tsx +++ b/tests/ui/ReportActionsViewTest.tsx @@ -60,7 +60,6 @@ const defaultPaginatedReportActionsResult: ReturnType Date: Fri, 24 Apr 2026 15:37:48 +0100 Subject: [PATCH 200/216] fix: reset `useScrollToEndOnNewMessageReceived` hook when report changes --- .../MoneyRequestReportActionsList.tsx | 1 + src/hooks/useScrollToEndOnNewMessageReceived.ts | 16 +++++++++++++++- src/pages/inbox/report/ReportActionsList.tsx | 3 ++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index e6ac97369307..1de2a13200ff 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -461,6 +461,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) hasNewestReportAction, setIsFloatingMessageCounterVisible, scrollToEnd: reportScrollManager.scrollToEnd, + resetKey: report.reportID, }); /** diff --git a/src/hooks/useScrollToEndOnNewMessageReceived.ts b/src/hooks/useScrollToEndOnNewMessageReceived.ts index cf910921d9a5..688db5c8f78f 100644 --- a/src/hooks/useScrollToEndOnNewMessageReceived.ts +++ b/src/hooks/useScrollToEndOnNewMessageReceived.ts @@ -1,4 +1,4 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useLayoutEffect, useRef} from 'react'; import type React from 'react'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; import usePrevious from './usePrevious'; @@ -43,8 +43,22 @@ function useScrollToEndOnNewMessageReceived({ }: UseScrollToEndOnPaginationMergeParams) { const previousLastIndex = useRef(lastActionID); const reportActionSize = useRef(visibleActionsLength); + const previousResetKeyRef = useRef(undefined); const prevHasNewestReportAction = usePrevious(hasNewestReportAction); + // When the hook is used across report navigations, baselines from the previous report must not drive scroll logic. + useLayoutEffect(() => { + if (resetKey === undefined) { + return; + } + if (previousResetKeyRef.current === resetKey) { + return; + } + previousResetKeyRef.current = resetKey; + previousLastIndex.current = lastActionID; + reportActionSize.current = visibleActionsLength; + }, [resetKey, lastActionID, visibleActionsLength]); + useEffect(() => { const didListSizeChange = sizeChangeType === 'grewFromReportActions' ? reportActionSize.current > (reportActionsLength ?? 0) : reportActionSize.current !== visibleActionsLength; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a937b7faafc2..065a569e2e1d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -457,7 +457,8 @@ function ReportActionsList({ hasNewestReportAction, setIsFloatingMessageCounterVisible, scrollToEnd: reportScrollManager.scrollToBottom, - resetKey: linkedReportActionID, + // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. + resetKey: `${report.reportID}:${linkedReportActionID ?? ''}`, }); useEffect(() => { From ed6cf8c1b581d29c599b3e1b2189ff03c2462ab5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 15:38:14 +0100 Subject: [PATCH 201/216] fix: reset report ref variables when report changes and components are re-used --- src/pages/inbox/report/ReportActionsList.tsx | 10 ++++++++++ src/pages/inbox/report/ReportActionsView.tsx | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 065a569e2e1d..38057c1b2a78 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -474,6 +474,11 @@ function ReportActionsList({ prevReportID = report.reportID; }, [report.reportID]); + // Same-screen report switches reuse this instance; per-report one-shot flags must not leak across reports. + useEffect(() => { + hasHeaderRendered.current = false; + }, [report.reportID]); + const isReportUnread = useMemo( () => isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction)), [report, transactionThreadReport, isReportArchived, lastAction], @@ -481,6 +486,11 @@ function ReportActionsList({ // Mark the report as read when the user initially opens the report and there are unread messages const didMarkReportAsReadInitially = useRef(false); + + useEffect(() => { + didMarkReportAsReadInitially.current = false; + }, [report.reportID]); + useEffect(() => { if (!isReportUnread || didMarkReportAsReadInitially.current) { didMarkReportAsReadInitially.current = true; diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 80b3c0fd7baa..add491a357fd 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -157,6 +157,10 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const reportPreviewAction = useMemo(() => getReportPreviewAction(report?.chatReportID, report?.reportID), [report?.chatReportID, report?.reportID]); const didLayout = useRef(false); + useEffect(() => { + didLayout.current = false; + }, [reportID]); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const isFocused = useIsFocused(); const prevShouldUseNarrowLayoutRef = useRef(shouldUseNarrowLayout); @@ -362,6 +366,14 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const isReportUnread = isUnread(report, transactionThreadReport, isReportArchived); const isReportUnreadInitially = useRef(isReportUnread); + // Without this, navigating to another report in the same mounted view keeps the previous + // report’s “initially unread” value and can block the skeleton or unread-anchor logic for the new report. + useEffect(() => { + isReportUnreadInitially.current = isUnread(report, transactionThreadReport, isReportArchived); + // Intentionally only on navigation: do not resync when read/unread or thread data updates in place. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportID]); + // When we first open a report with a linked report action, // we need to wait for the results from the OpenReport api call, // if the linked report action is not stored in Onyx. From 788f48a59774c8e6f49a9d5424cd180c54ee3ed9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 15:42:09 +0100 Subject: [PATCH 202/216] revert: `RenderTaskQueue` delay logic --- src/components/FlatList/RenderTaskQueue.tsx | 29 ++++++--------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/components/FlatList/RenderTaskQueue.tsx b/src/components/FlatList/RenderTaskQueue.tsx index 5b00bebb75c0..9346bbe53b31 100644 --- a/src/components/FlatList/RenderTaskQueue.tsx +++ b/src/components/FlatList/RenderTaskQueue.tsx @@ -9,7 +9,7 @@ class RenderTaskQueue { private isRendering = false; - private handler: ((info: RenderInfo) => void) | undefined = undefined; + private handler: (info: RenderInfo) => void = () => {}; private timeout: NodeJS.Timeout | null = null; @@ -19,19 +19,12 @@ class RenderTaskQueue { this.onIsRenderingChange = onIsRenderingChange; } - add(info: RenderInfo, startRendering = true) { + add(info: RenderInfo) { this.renderInfos.push(info); - if (!this.isRendering && startRendering) { - this.renderWithDelay(); - } - } - - start() { - if (this.isRendering) { - return; + if (!this.isRendering) { + this.render(); } - this.renderWithDelay(); } setHandler(handler: (info: RenderInfo) => void) { @@ -39,7 +32,6 @@ class RenderTaskQueue { } cancel() { - this.isRendering = false; if (this.timeout == null) { return; } @@ -47,12 +39,6 @@ class RenderTaskQueue { this.onIsRenderingChange?.(false); } - private renderWithDelay() { - this.timeout = setTimeout(() => { - this.render(); - }, RENDER_DELAY); - } - private render() { const info = this.renderInfos.shift(); if (!info) { @@ -63,11 +49,12 @@ class RenderTaskQueue { this.isRendering = true; this.onIsRenderingChange?.(true); - this.handler?.(info); + this.handler(info); - this.renderWithDelay(); + this.timeout = setTimeout(() => { + this.render(); + }, RENDER_DELAY); } } export default RenderTaskQueue; -export type {RenderInfo}; From ff2bb8d19c3622d2d96278f6fee0cdaee5055da9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:08:14 +0100 Subject: [PATCH 203/216] fix: Do not default string IDs to any value --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 38057c1b2a78..902364bc71e9 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -458,7 +458,7 @@ function ReportActionsList({ setIsFloatingMessageCounterVisible, scrollToEnd: reportScrollManager.scrollToBottom, // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. - resetKey: `${report.reportID}:${linkedReportActionID ?? ''}`, + resetKey: `${report.reportID}:${linkedReportActionID}`, }); useEffect(() => { From 5b59f8b2cfe2dc8cd99753c1960dae31d858138c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:35:11 +0100 Subject: [PATCH 204/216] refactor: simplify `listID` key for FlashList --- src/libs/NumberUtils.ts | 10 +---- src/pages/inbox/report/ReportActionsList.tsx | 4 +- src/pages/inbox/report/ReportActionsView.tsx | 41 ++++--------------- .../perf-test/ReportActionsList.perf-test.tsx | 2 +- 4 files changed, 12 insertions(+), 45 deletions(-) diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts index 19ff746a48d5..8dcdb6fd5791 100644 --- a/src/libs/NumberUtils.ts +++ b/src/libs/NumberUtils.ts @@ -69,12 +69,4 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -function generateNewRandomInt(old: number, min: number, max: number): number { - let newNum = old; - while (newNum === old) { - newNum = generateRandomInt(min, max); - } - return newNum; -} - -export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundToTwoDecimalPlaces, clamp, generateNewRandomInt}; +export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundToTwoDecimalPlaces, clamp}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 902364bc71e9..9b6eb27bd85c 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -132,8 +132,8 @@ type ReportActionsListProps = { /** Whether the composer is in full size */ isComposerFullSize?: boolean; - /** ID of the list */ - listID: number; + /** Stable key to remount the list when the deep-linked action or unread anchor (or report) changes */ + listID: string; /** Whether the optimistic CREATED report action was added */ hasCreatedActionAdded?: boolean; diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index add491a357fd..4287c72a9f79 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -13,7 +13,6 @@ import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; -import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -25,7 +24,7 @@ import DateUtils from '@libs/DateUtils'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; +import {rand64} from '@libs/NumberUtils'; import { getCombinedReportActions, getFilteredReportActionsForReportView, @@ -66,8 +65,6 @@ type ReportActionsViewProps = { onLayout?: (event: LayoutChangeEvent) => void; }; -let listOldID = Math.round(Math.random() * 100); - function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const route = useRoute(); const reportActionIDFromRoute = route?.params?.reportActionID; @@ -89,7 +86,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { hasOlderActions, sortedAllReportActions, oldestUnreadReportAction, - linkedAction, } = usePaginatedReportActions(reportID, reportActionIDFromRoute, { shouldLinkToOldestUnreadReportAction: true, treatAsNoPaginationAnchor, @@ -153,7 +149,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const prevReportActionID = usePrevious(reportActionIDFromRoute); const reportPreviewAction = useMemo(() => getReportPreviewAction(report?.chatReportID, report?.reportID), [report?.chatReportID, report?.reportID]); const didLayout = useRef(false); @@ -184,20 +179,11 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { updateLoadingInitialReportAction(report?.reportID ?? reportID); }, [isOffline, report?.reportID, reportID, reportActionIDFromRoute]); - const previousOldestUnreadReportActionID = usePrevious(oldestUnreadReportAction?.reportActionID); - - // Change the list ID only for comment linking to get the positioning right - const listID = useMemo(() => { - if (!reportActionIDFromRoute && !prevReportActionID && oldestUnreadReportAction?.reportActionID === previousOldestUnreadReportActionID) { - // Keep the old list ID since we're not in the Comment Linking flow - return listOldID; - } - const newID = generateNewRandomInt(listOldID, 1, Number.MAX_SAFE_INTEGER); - listOldID = newID; - - return newID; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [route, reportActionIDFromRoute, oldestUnreadReportAction?.reportActionID, previousOldestUnreadReportActionID]); + // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. + const listID = useMemo( + () => [reportID, reportActionIDFromRoute, oldestUnreadReportAction?.reportActionID].join(':'), + [reportID, reportActionIDFromRoute, oldestUnreadReportAction?.reportActionID], + ); // When we are offline before opening an IOU/Expense report, // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. @@ -298,7 +284,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { useEffect(() => { // update ref with current state prevShouldUseNarrowLayoutRef.current = shouldUseNarrowLayout; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [shouldUseNarrowLayout, reportActions, isReportFullyVisible]); const allReportActionIDs = useMemo(() => allReportActions?.map((action) => action.reportActionID) ?? [], [allReportActions]); @@ -371,28 +356,18 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { useEffect(() => { isReportUnreadInitially.current = isUnread(report, transactionThreadReport, isReportArchived); // Intentionally only on navigation: do not resync when read/unread or thread data updates in place. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportID]); - - // When we first open a report with a linked report action, - // we need to wait for the results from the OpenReport api call, - // if the linked report action is not stored in Onyx. - const isLinkedMessagePageLoadingInitially = !!reportActionIDFromRoute && !linkedAction; + }, [isReportArchived, report, reportID, transactionThreadReport]); // Same for unread messages, we need to wait for the results from the OpenReport api call, // if the oldest unread report action is not stored in Onyx. const isUnreadMessagePageLoadingInitially = !reportActionIDFromRoute && isReportUnreadInitially.current && !oldestUnreadReportAction; - const shouldWaitForOpenReportResultInitially = isLinkedMessagePageLoadingInitially || isUnreadMessagePageLoadingInitially; - - // console.log({isLinkedMessageLoading: isLinkedMessagePageLoading, isUnreadMessageLoading: isUnreadMessagePageLoading, shouldWaitForOpenReportResult}); - // When opening an unread report, it is very likely that the message we will open to is not the latest, // which is the only one we will have in cache. const isInitiallyLoadingReport = isReportUnread && !!reportMetadata?.isLoadingInitialReportActions && (isOffline || reportActions.length <= 1); // Once all the above conditions are met, we can consider the report ready. - const isReportReady = !isInitiallyLoadingReport && !shouldWaitForOpenReportResultInitially; + const isReportReady = !isInitiallyLoadingReport && !isUnreadMessagePageLoadingInitially; useEffect(() => { if (!shouldShowSkeleton || !report) { diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 35920e389cba..f0cec3cf49ac 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -111,7 +111,7 @@ function ReportActionsListWrapper() { report={report} onLayout={mockOnLayout} onScroll={mockOnScroll} - listID={1} + listID="perf-test-list" loadOlderChats={mockLoadChats} loadNewerChats={mockLoadChats} hasNewerActions={false} From 90d9b6de2e598033651e9e05aa92c07927341ab6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:36:04 +0100 Subject: [PATCH 205/216] refactor: extract variable --- tests/perf-test/ReportActionsList.perf-test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index f0cec3cf49ac..cad58d62e261 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -22,6 +22,8 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; +const REPORT_ACTIONS_LIST_ID = 'perf-test-list'; + type LazyLoadLHNTestUtils = { fakePersonalDetails: PersonalDetailsList; }; @@ -111,7 +113,7 @@ function ReportActionsListWrapper() { report={report} onLayout={mockOnLayout} onScroll={mockOnScroll} - listID="perf-test-list" + listID={REPORT_ACTIONS_LIST_ID} loadOlderChats={mockLoadChats} loadNewerChats={mockLoadChats} hasNewerActions={false} From 12c3484aeed5c3a1916e69dac8aae05a9cf4fc81 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:43:29 +0100 Subject: [PATCH 206/216] refactor: jest `RenderTaskQueue` setup --- jest/setup.ts | 8 +++----- src/components/FlatList/RenderTaskQueue.tsx | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 05e5208261e0..f7c9d178dae6 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -266,15 +266,13 @@ jest.mock( '@components/FlatList/RenderTaskQueue', () => class SyncRenderTaskQueue { - private handler: ((info: unknown) => void) | undefined = undefined; + private handler: (info: unknown) => void = () => {}; add(info: RenderInfo) { - this.handler?.(info); + this.handler(info); } - start() {} - - setHandler(handler: (info: unknown) => void) { + setHandler(handler: () => void) { this.handler = handler; } diff --git a/src/components/FlatList/RenderTaskQueue.tsx b/src/components/FlatList/RenderTaskQueue.tsx index 9346bbe53b31..4f0d791cad29 100644 --- a/src/components/FlatList/RenderTaskQueue.tsx +++ b/src/components/FlatList/RenderTaskQueue.tsx @@ -58,3 +58,4 @@ class RenderTaskQueue { } export default RenderTaskQueue; +export type {RenderInfo}; From 11001a97ffd232bea21b1de2624dec2f0c45a913 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:49:14 +0100 Subject: [PATCH 207/216] fix: invalidly typed tooling in `Pagination` middleware --- src/libs/Middleware/Pagination.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index a00962954c5a..f922e4f9be32 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -127,18 +127,6 @@ const Pagination: Middleware = (requestResponse, request) => { const pagesCollections = pages.get(pageCollectionKey) ?? {}; const existingPages: Pages = pagesCollections[pageKey] ?? []; - // Some tooling resolves `@libs/PaginationUtils` as untyped, so we add explicit local types - // to avoid unsafe-call/assignment linting while keeping runtime behavior identical. - const mergePagesByIDOverlapTyped: (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages = mergePagesByIDOverlap as unknown as < - TResource, - >( - sortedItems: TResource[], - pages: Pages, - getItemID: (item: TResource) => string, - ) => Pages; - const mergeAndSortContinuousPagesTyped: (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages = - mergeAndSortContinuousPages as unknown as (sortedItems: TResource[], pages: Pages, getItemID: (item: TResource) => string) => Pages; - const isMiddleInitialSlice = type === 'initial' && !cursorID && response.hasNewerActions === true && response.hasOlderActions === true; // Only strip PAGINATION_START_ID from cached pages when the server explicitly confirms newer actions exist. @@ -147,8 +135,8 @@ const Pagination: Middleware = (requestResponse, request) => { const sanitizedExistingPages = shouldStripStartMarker ? existingPages.map((page) => page.filter((id) => id !== CONST.PAGINATION_START_ID)) : existingPages; const mergedPages: Pages = isMiddleInitialSlice - ? mergePagesByIDOverlapTyped(sortedAllItems, [...sanitizedExistingPages, newPage], getItemID) - : mergeAndSortContinuousPagesTyped(sortedAllItems, [...sanitizedExistingPages, newPage], getItemID); + ? mergePagesByIDOverlap(sortedAllItems, [...sanitizedExistingPages, newPage], getItemID) + : mergeAndSortContinuousPages(sortedAllItems, [...sanitizedExistingPages, newPage], getItemID); (response.onyxData as AnyOnyxUpdate[]).push({ key: pageKey, From 0577eae2fcc6a1fe0c170dcb5dc8940850f8769c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:52:06 +0100 Subject: [PATCH 208/216] refactor: enhance pagination logic to conditionally ignore reportActionID based on treatAsNoPaginationAnchor --- src/hooks/usePaginatedReportActions.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index 39bdc573867d..0dd1df4cf0c0 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -90,14 +90,27 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID? return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, id); }, [id, reportActionPages, sortedAllReportActions]); - const linkedAction = useMemo(() => (reportActionID ? resourceItem?.item : undefined), [resourceItem?.item, reportActionID]); + // When `treatAsNoPaginationAnchor` is set, we intentionally ignore `reportActionID` for pagination + // (same as `id` above), so we must not surface a "linked" action from that id either. + const linkedAction = useMemo(() => { + if (treatAsNoPaginationAnchor) { + return undefined; + } + if (!reportActionID) { + return undefined; + } + return resourceItem?.item; + }, [resourceItem?.item, reportActionID, treatAsNoPaginationAnchor]); const oldestUnreadReportAction = useMemo(() => { + if (treatAsNoPaginationAnchor) { + return undefined; + } if (shouldLinkToOldestUnreadReportAction && resourceItem && !reportActionID) { return resourceItem.item; } return undefined; - }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID]); + }, [resourceItem, shouldLinkToOldestUnreadReportAction, reportActionID, treatAsNoPaginationAnchor]); return { reportActions, From 45a45ffe4cbf46cc4271c6a5a115809236cdd09e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:55:00 +0100 Subject: [PATCH 209/216] refactor: remove unnecessary delayed rendering logic in `useFlatListScrollKey` --- .../FlatList/hooks/useFlatListScrollKey.ts | 139 +++++------------- 1 file changed, 40 insertions(+), 99 deletions(-) diff --git a/src/components/FlatList/hooks/useFlatListScrollKey.ts b/src/components/FlatList/hooks/useFlatListScrollKey.ts index 48e9a7986e2a..85ee67fb18a7 100644 --- a/src/components/FlatList/hooks/useFlatListScrollKey.ts +++ b/src/components/FlatList/hooks/useFlatListScrollKey.ts @@ -1,9 +1,9 @@ -import React, {useCallback, useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {ForwardedRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; import {View} from 'react-native'; -import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; +import getInitialPaginationSize from '@components/FlatList/getInitialPaginationSize'; import RenderTaskQueue from '@components/FlatList/RenderTaskQueue'; import type {FlatListInnerRefType} from '@components/FlatList/types'; import type {ScrollViewProps} from '@components/ScrollView'; @@ -12,10 +12,6 @@ import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; import useFlatListHandle from './useFlatListHandle'; -const PAGINATION_SIZE = 15; -const INITIAL_SCROLL_DELAY = 200; -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - type FlatListScrollKeyProps = { ref?: ForwardedRef>; data: T[]; @@ -25,13 +21,13 @@ type FlatListScrollKeyProps = { onStartReached?: ((info: {distanceFromStart: number}) => void) | null; shouldEnableAutoScrollToTopThreshold?: boolean; renderItem: ListRenderItem; - initialNumToRender?: number; - didInitialContentRender?: boolean; + remainingItemsToDisplay?: number; onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; - onInitiallyLoaded?: () => void; }; -function useFlatListScrollKey({ +const AUTOSCROLL_TO_TOP_THRESHOLD = 250; + +export default function useFlatListScrollKey({ data, keyExtractor, initialScrollKey, @@ -40,10 +36,8 @@ function useFlatListScrollKey({ shouldEnableAutoScrollToTopThreshold, renderItem, ref, - initialNumToRender = 10, - didInitialContentRender = true, + remainingItemsToDisplay, onScrollToIndexFailed, - onInitiallyLoaded, }: FlatListScrollKeyProps) { // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more @@ -63,51 +57,29 @@ function useFlatListScrollKey({ // Therefore, we need to duplicate the data to ensure data.length >= 2 const shouldDuplicateData = useMemo(() => !inverted && data.length === 1 && isInitialData && getPlatform() === CONST.PLATFORM.WEB, [data.length, inverted, isInitialData]); - const {displayedData, negativeScrollIndex} = useMemo(() => { + const displayedData = useMemo(() => { if (shouldDuplicateData) { - return {displayedData: [{...data.at(0), reportActionID: '0'} as T, ...data], negativeScrollIndex: data.length}; + return [{...data.at(0), reportActionID: '0'} as T, ...data]; } - - // If no initially linked item is set, we render the entire dataset. if (currentDataIndex <= 0) { - return {displayedData: data, negativeScrollIndex: data.length}; + return data; } - // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. // So we will need to return at least the last 2 elements in this case. const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; + // We always render the list from the highlighted item to the end of the list because: + // - With an inverted FlatList, items are rendered from bottom to top, + // so the highlighted item stays at the bottom and within the visible viewport. + // - With a non-inverted (base) FlatList, items are rendered from top to bottom, + // making the highlighted item appear at the top of the list. + // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place + // as the rest of the items are appended. + return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); + }, [currentDataIndex, data, inverted, isInitialData, shouldDuplicateData]); - // On first render, we only render the items up to the initially linked item. - // This allows `maintainVisibleContentPosition` to render the initially linked item at the bottom of the list. - const itemIndex = Math.max(0, currentDataIndex - (isInitialData ? offset : PAGINATION_SIZE)); - - // On the first render, we need to ensure that we render at least the initial number of items. - // If the initially linked item is closer to the end of the list, we need to render more items and - // therefore the initially linked element will not be rendered right at the bottom of the list. - const minInitialIndex = Math.max(0, data.length - initialNumToRender); - const firstItemIndex = Math.min(itemIndex, minInitialIndex); - - return { - // We always render the list from the highlighted item to the end of the list because: - // - With an inverted FlatList, items are rendered from bottom to top, - // so the highlighted item stays at the bottom and within the visible viewport. - // - With a non-inverted (base) FlatList, items are rendered from top to bottom, - // making the highlighted item appear at the top of the list. - // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place - // as the rest of the items are appended and - // the dataset only contains items up to the initially linked item. - displayedData: data.slice(firstItemIndex), - // This is needed to allow scrolling to the initially linked item, when it's on the first page of the dataset. - negativeScrollIndex: Math.min(data.length, data.length - itemIndex), - }; - }, [currentDataIndex, data, initialNumToRender, inverted, isInitialData, shouldDuplicateData]); - - const initialNegativeScrollIndex = useRef(negativeScrollIndex); const isLoadingData = data.length > displayedData.length; const wasLoadingData = usePrevious(isLoadingData); - const remainingItemsToDisplay = data.length - displayedData.length; - - const listRef = useRef | null>(null); + const dataIndexDifference = data.length - displayedData.length; // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); @@ -117,90 +89,60 @@ function useFlatListScrollKey({ }; }, [renderQueue]); - // If the unread message is on the first page, scroll to the end once the content is measured and the data is loaded - const isMessageOnFirstPage = useRef(currentDataIndex > Math.max(0, data.length - initialNumToRender)); - const didScroll = useRef(false); - - // When we are initially showing a message on the first page of the whole dataset, - // we don't want to immediately start rendering the list. - // Instead, we wait for the initial data to be displayed, scroll to the item manually and - // then start rendering more items. - useEffect(() => { - if (didScroll.current || !isMessageOnFirstPage.current || !didInitialContentRender) { - return; - } - - listRef.current?.scrollToIndex({animated: false, index: displayedData.length - initialNegativeScrollIndex.current}); - - // We need to wait for a few milliseconds until the scrolling is done, - // before we start rendering additional items in the list. - setTimeout(() => { - didScroll.current = true; - renderQueue.start(); - }, INITIAL_SCROLL_DELAY); - }, [currentDataIndex, data.length, displayedData.length, didInitialContentRender, initialNumToRender, isMessageOnFirstPage, renderQueue, listRef]); - - renderQueue.setHandler((info: RenderInfo) => { + renderQueue.setHandler((info) => { if (!isLoadingData) { onStartReached?.(info); } - - if (isInitialData) { - setIsInitialData(false); - onInitiallyLoaded?.(); - } - + setIsInitialData(false); const firstDisplayedItem = displayedData.at(0); setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : null); }); const handleStartReached = useCallback( - (info: RenderInfo) => { - // Same as above, we want to prevent rendering more items until the linked item on the first page has been scrolled to. - const startRendering = didScroll.current || !isMessageOnFirstPage.current; - renderQueue.add(info, startRendering); + (info: {distanceFromStart: number}) => { + renderQueue.add(info); }, [renderQueue], ); - const onInitialEmptyDataset = useEffectEvent(() => { + useEffect(() => { // In cases where the data is empty on the initial render, `handleStartReached` will never be triggered. // We'll manually invoke it in this scenario. if (inverted || data.length > 0) { return; } handleStartReached({distanceFromStart: 0}); - }); - - useEffect(() => { - onInitialEmptyDataset(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); - const maintainVisibleContentPosition = useMemo(() => { + const maintainVisibleContentPosition = useMemo(() => { if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { return undefined; } - const enableAutoScrollToTopThreshold = shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData; - - return { + const config: ScrollViewProps['maintainVisibleContentPosition'] = { // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: displayedData.length ? Math.min(1, displayedData.length - 1) : 0, - autoscrollToTopThreshold: enableAutoScrollToTopThreshold ? AUTOSCROLL_TO_TOP_THRESHOLD : undefined, + minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, }; - }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData, displayedData.length]); + + if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { + config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; + } + + return config; + }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); const handleRenderItem = useCallback( ({item, index, separators}: ListRenderItemInfo) => { // Adjust the index passed here so it matches the original data. if (shouldDuplicateData && index === 1) { - return React.createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + remainingItemsToDisplay, separators})); + return React.createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); } - return renderItem({item, index: index + remainingItemsToDisplay, separators}); + return renderItem({item, index: index + dataIndexDifference, separators}); }, - [shouldDuplicateData, renderItem, remainingItemsToDisplay], + [shouldDuplicateData, renderItem, dataIndexDifference], ); useEffect(() => { @@ -219,6 +161,7 @@ function useFlatListScrollKey({ }); }, [inverted, isInitialData, isQueueRendering]); + const listRef = useRef | null>(null); useFlatListHandle({ ref, listRef, @@ -238,6 +181,4 @@ function useFlatListScrollKey({ }; } -export default useFlatListScrollKey; - export {AUTOSCROLL_TO_TOP_THRESHOLD}; From 086f66087350677d5636b5757f867dc09927de58 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:55:05 +0100 Subject: [PATCH 210/216] fix: simplify loop --- src/libs/PaginationUtils.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index 972c73e2097f..c5e8c8e386e6 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -212,12 +212,8 @@ function getFirstAndLastIndexForPage(page: string[], idToIndex: Map= 0; i--) { - const id = page.at(i); - if (id === CONST.PAGINATION_END_ID) { - lastIndex = lastIndexInSortedItems; - break; - } + if (page.at(-1) === CONST.PAGINATION_END_ID) { + lastIndex = lastIndexInSortedItems; } if (firstIndex === undefined || lastIndex === undefined) { From 7eeb97392ded4feddf1f7e82c191d010db93c916 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 16:57:36 +0100 Subject: [PATCH 211/216] fix: use platform-specific pagination sizes --- src/CONST/index.ts | 5 ++++- .../FlatList/getInitialPaginationSize/index.native.ts | 3 +++ src/components/FlatList/getInitialPaginationSize/index.ts | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/components/FlatList/getInitialPaginationSize/index.native.ts create mode 100644 src/components/FlatList/getInitialPaginationSize/index.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3a5ddcabe944..65a1b800d826 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6685,6 +6685,9 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + MOBILE_PAGINATION_SIZE: 15, + WEB_PAGINATION_SIZE: 30, + /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { HEIGHT: 220, @@ -9559,7 +9562,7 @@ const CONST = { AUTHENTICATE_PAYMENT: 'SettingsSubscription-AuthenticatePayment', VIEW_PAYMENT_HISTORY: 'SettingsSubscription-ViewPaymentHistory', REQUEST_REFUND: 'SettingsSubscription-RequestRefund', - REQUEST_EARLY_CANCELLATION: 'SettingsSubscription-RequestEarlyCancellation', + CANCEL_SUBSCRIPTION: 'SettingsSubscription-CancelSubscription', }, SETTINGS_HELP: { CONCIERGE_CHAT: 'SettingsHelp-ConciergeChat', diff --git a/src/components/FlatList/getInitialPaginationSize/index.native.ts b/src/components/FlatList/getInitialPaginationSize/index.native.ts new file mode 100644 index 000000000000..195448f7e450 --- /dev/null +++ b/src/components/FlatList/getInitialPaginationSize/index.native.ts @@ -0,0 +1,3 @@ +import CONST from '@src/CONST'; + +export default CONST.MOBILE_PAGINATION_SIZE; diff --git a/src/components/FlatList/getInitialPaginationSize/index.ts b/src/components/FlatList/getInitialPaginationSize/index.ts new file mode 100644 index 000000000000..87ec6856aa20 --- /dev/null +++ b/src/components/FlatList/getInitialPaginationSize/index.ts @@ -0,0 +1,3 @@ +import CONST from '@src/CONST'; + +export default CONST.WEB_PAGINATION_SIZE; From f7c9b2319fcf71af173f4344dbb68f87792c5c13 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 17:00:07 +0100 Subject: [PATCH 212/216] test: add tests for Pagination util functions: `mergePagesByIDOverlap`, `selectNewestPageWithIndex` and `prunePagesToNewestWindow` --- tests/unit/PaginationUtilsTest.ts | 140 +++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/tests/unit/PaginationUtilsTest.ts b/tests/unit/PaginationUtilsTest.ts index 28e754ce5ff7..b67e307bbe58 100644 --- a/tests/unit/PaginationUtilsTest.ts +++ b/tests/unit/PaginationUtilsTest.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import type {Pages} from '@src/types/onyx'; -import PaginationUtils from '../../src/libs/PaginationUtils'; +import PaginationUtils, {selectNewestPageWithIndex} from '../../src/libs/PaginationUtils'; type Item = { id: string; @@ -604,4 +604,142 @@ describe('PaginationUtils', () => { expect(result).toStrictEqual(expectedResult); }); }); + + describe('mergePagesByIDOverlap', () => { + it('merges pages that share a non-marker id (overlap) without requiring index adjacency in the same way as mergeAndSort', () => { + const sortedItems = createItems(['5', '4', '3', '2', '1']); + const pages: Pages = [ + ['4', '3', '2'], + ['3', '2', '1'], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([['4', '3', '2', '1']]); + }); + + it('merges when the first id of a page matches the last id of the previous page (chain)', () => { + const sortedItems = createItems(['5', '4', '3', '2', '1']); + const pages: Pages = [ + ['5', '4', '3'], + ['3', '2', '1'], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([['5', '4', '3', '2', '1']]); + }); + + it('does not merge disjoint windows with no shared ids (middle-of-chat / sparse local set)', () => { + const sortedItems = createItems(['5', '4', '2', '1']); + const pages: Pages = [ + ['5', '4'], + ['2', '1'], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([ + ['5', '4'], + ['2', '1'], + ]); + }); + + it('returns an empty array when input pages is empty', () => { + expect(PaginationUtils.mergePagesByIDOverlap(createItems(['1']), [], getID)).toStrictEqual([]); + }); + + it('strips ids not present in sortedItems from stored pages', () => { + const sortedItems = createItems(['4', '3']); + const pages: Pages = [['6', '5', '4', '3', '2', '1']]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([['4', '3']]); + }); + + it('applies start/end markers when merging', () => { + const sortedItems = createItems(['1', '2', '3']); + const pages: Pages = [ + [CONST.PAGINATION_START_ID, '1', '2', '3'], + ['2', '3', CONST.PAGINATION_END_ID], + ]; + const result = PaginationUtils.mergePagesByIDOverlap(sortedItems, pages, getID); + expect(result).toStrictEqual([[CONST.PAGINATION_START_ID, '1', '2', '3', CONST.PAGINATION_END_ID]]); + }); + }); + + describe('selectNewestPageWithIndex', () => { + it('returns undefined for an empty list', () => { + expect(selectNewestPageWithIndex([])).toBeUndefined(); + }); + + it('returns the only page when there is a single page', () => { + const only = { + ids: ['3', '2', '1'], + firstID: '3', + firstIndex: 0, + lastID: '1', + lastIndex: 2, + }; + expect(selectNewestPageWithIndex([only])).toBe(only); + }); + + it('prefers the page whose firstID is the pagination start marker', () => { + const withStart = { + ids: [CONST.PAGINATION_START_ID, '2', '1'], + firstID: CONST.PAGINATION_START_ID, + firstIndex: 2, + lastID: '1', + lastIndex: 4, + }; + const newerByIndex = { + ids: ['5', '4'], + firstID: '5', + firstIndex: 0, + lastID: '4', + lastIndex: 1, + }; + expect(selectNewestPageWithIndex([newerByIndex, withStart])).toBe(withStart); + expect(selectNewestPageWithIndex([withStart, newerByIndex])).toBe(withStart); + }); + + it('when no start marker, picks the page with the smallest firstIndex (chronologically newest in descending-sorted data)', () => { + const olderWindow = { + ids: ['2', '1'], + firstID: '2', + firstIndex: 3, + lastID: '1', + lastIndex: 4, + }; + const newerWindow = { + ids: ['5', '4'], + firstID: '5', + firstIndex: 0, + lastID: '4', + lastIndex: 1, + }; + expect(selectNewestPageWithIndex([olderWindow, newerWindow])).toBe(newerWindow); + }); + }); + + describe('prunePagesToNewestWindow', () => { + it('returns pages unchanged when there is at most one page', () => { + const sortedItems = createItems(['1', '2', '3']); + expect(PaginationUtils.prunePagesToNewestWindow(sortedItems, [], getID)).toStrictEqual([]); + expect(PaginationUtils.prunePagesToNewestWindow(sortedItems, [['1', '2']], getID)).toStrictEqual([['1', '2']]); + }); + + it('collapses to the newest window by firstIndex when no start marker is present', () => { + const sortedItems = createItems(['5', '4', '3', '2', '1']); + const pages: Pages = [ + ['2', '1'], + ['5', '4'], + ]; + const result = PaginationUtils.prunePagesToNewestWindow(sortedItems, pages, getID); + expect(result).toStrictEqual([['5', '4']]); + }); + + it('keeps the page that includes the start marker (ids are expanded the same way as in getPagesWithIndexes)', () => { + const sortedItems = createItems(['3', '2', '1']); + const pages: Pages = [ + ['3', '2'], + [CONST.PAGINATION_START_ID, '1'], + ]; + const result = PaginationUtils.prunePagesToNewestWindow(sortedItems, pages, getID); + expect(result).toStrictEqual([[CONST.PAGINATION_START_ID, '3', '2', '1']]); + }); + }); }); From b12df9afef36ddb5c6024f6efe6a25dc31696955 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 18:12:59 +0100 Subject: [PATCH 213/216] fix: remove unused `initialNumToRender` property in `BaseFlatListWithScrollKey` --- .../FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx index d62c3586ac71..6201e5f337dc 100644 --- a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx @@ -28,7 +28,6 @@ function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKey keyExtractor, initialScrollKey, inverted: false, - initialNumToRender, onStartReached, shouldEnableAutoScrollToTopThreshold, renderItem, From 460d0897ebea57afef563aa39dd1733bacbbce9e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 18:13:31 +0100 Subject: [PATCH 214/216] Revert "fix: remove unused `initialNumToRender` property in `BaseFlatListWithScrollKey`" This reverts commit b12df9afef36ddb5c6024f6efe6a25dc31696955. --- .../FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx index 6201e5f337dc..d62c3586ac71 100644 --- a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx @@ -28,6 +28,7 @@ function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKey keyExtractor, initialScrollKey, inverted: false, + initialNumToRender, onStartReached, shouldEnableAutoScrollToTopThreshold, renderItem, From f5e3925d6c76f6dcb55da1a74b2e25e12fb3c618 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 18:14:19 +0100 Subject: [PATCH 215/216] fix: remove unused properties in `BaseFlatListWithScrollKey` --- .../FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx | 4 ---- src/components/FlatList/FlatListWithScrollKey/types.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx index d62c3586ac71..b28f1d3e5dc4 100644 --- a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx +++ b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx @@ -19,7 +19,6 @@ function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKey onScrollBeginDrag, onWheel, onTouchStartCapture, - onInitiallyLoaded, initialNumToRender, ...restProps } = props; @@ -28,12 +27,10 @@ function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKey keyExtractor, initialScrollKey, inverted: false, - initialNumToRender, onStartReached, shouldEnableAutoScrollToTopThreshold, renderItem, ref, - onInitiallyLoaded, }); const isLoadingData = useRef(true); @@ -58,7 +55,6 @@ function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKey // eslint-disable-next-line react/jsx-props-no-spreading {...restProps} data={displayedData} - initialNumToRender={initialNumToRender} maintainVisibleContentPosition={maintainVisibleContentPosition} onStartReached={handleStartReached} renderItem={handleRenderItem} diff --git a/src/components/FlatList/FlatListWithScrollKey/types.ts b/src/components/FlatList/FlatListWithScrollKey/types.ts index 55dc8f677c79..5a8145be74c3 100644 --- a/src/components/FlatList/FlatListWithScrollKey/types.ts +++ b/src/components/FlatList/FlatListWithScrollKey/types.ts @@ -8,7 +8,6 @@ type BaseFlatListWithScrollKeyProps = Omit, 'data' | 'initia shouldEnableAutoScrollToTopThreshold?: boolean; renderItem: ListRenderItem; onContentSizeChange?: (contentWidth: number, contentHeight: number, isInitialData?: boolean) => void; - onInitiallyLoaded?: () => void; ref: ForwardedRef; }; From 3630817640c60ac604c24f1cbacce613df176559 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 24 Apr 2026 18:27:49 +0100 Subject: [PATCH 216/216] fix: list flickers when report action is marked unread --- src/components/FlashList/useFlashListScrollKey.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/FlashList/useFlashListScrollKey.ts b/src/components/FlashList/useFlashListScrollKey.ts index b88bccf827fb..bc400a3078cc 100644 --- a/src/components/FlashList/useFlashListScrollKey.ts +++ b/src/components/FlashList/useFlashListScrollKey.ts @@ -24,7 +24,13 @@ export default function useFlashListScrollKey({data, keyExtractor, initialScr // linked item through the data swap. // RAF 2: pinning has happened, disable MVCP so it doesn't cause later jumps. useEffect(() => { - if (!isInitialRender || !initialScrollKey) { + // Without an anchor on this frame, we are not doing the deep-link slice handoff; clear the flag so a key that + // appears later (e.g. marking a message unread) cannot reuse the "first paint" slice path. + if (!initialScrollKey) { + setIsInitialRender(false); + return; + } + if (!isInitialRender) { return; } requestAnimationFrame(() => {