diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 97a0cd1f97f7..933ae9747913 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -63,6 +63,7 @@ import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {View} from 'react-native'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionIndexContext from './ReportActionIndexContext'; @@ -389,6 +390,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setTreatAsNoPaginationAnchor, }); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); + + const handleListLoad = () => { + onLoad(); + setLoadedInitialViewportListID(listID); + }; + const loadOlderChatsOnStartReached = () => { if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { return; @@ -601,13 +610,20 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct alignItemsAtEnd={!shouldBeAlignedToTop} // Only follow the real latest page. Older/linked windows must retain their visible anchor. maintainScrollAtEnd={!hasNewerActions && {animated: false}} - // Keyboard avoidance can shrink the viewport by almost a full screen before LegendList evaluates end proximity. // Leave the end-follow region as soon as the user starts reading older messages. maintainScrollAtEndThreshold={0.01} maintainVisibleContentPosition - onLoad={onLoad} + onLoad={handleListLoad} onContentSizeChange={() => trackVerticalScrolling(undefined)} /> + {shouldShowInitialViewportSkeleton && ( + + + + )} ); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c9c51c1f0d34..60eed393ac14 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -134,12 +134,16 @@ const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest. // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. const mockLegendListMount = jest.fn(); const mockLegendListUnmount = jest.fn(); +let mockShouldCallLegendListOnLoad = true; jest.mock('@legendapp/list/react-native', () => { const reactModule = jest.requireActual('react'); return { - LegendList: jest.fn(() => { + LegendList: jest.fn(({onLoad}: {onLoad?: () => void}) => { reactModule.useEffect(() => { mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + } return () => { mockLegendListUnmount(); }; @@ -183,6 +187,7 @@ type MockLegendListProps = { maintainScrollAtEnd?: {animated: boolean} | false; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; + onLoad?: () => void; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -300,6 +305,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); mockHasOnceLoadedReportActions = true; + mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -440,6 +446,37 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); }); + it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + it('keeps the initial actions visible until the hydrated page is complete', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; @@ -1217,9 +1254,10 @@ describe('ReportActionsList (body)', () => { expect(mockStartSession).toHaveBeenCalled(); }); - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { + it('should cover cached actions until the refreshed report finishes hydrating', () => { // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, - // but report actions persist in Onyx cache. We should render them immediately (production behavior). + // but report actions persist in Onyx cache. Keep the cached list covered until OpenReport + // finishes so the final hydrated viewport is the first report content the user sees. setupMainDMConciergeMocks(SESSION_START, false, false); mockUsePaginatedReportActions.mockReturnValue({ @@ -1230,7 +1268,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockLegendList).toHaveBeenCalled(); });