Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/pages/inbox/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -389,6 +390,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct
setTreatAsNoPaginationAnchor,
});

const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState<string>();
const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID);

const handleListLoad = () => {
onLoad();
setLoadedInitialViewportListID(listID);
};

const loadOlderChatsOnStartReached = () => {
if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) {
return;
Expand Down Expand Up @@ -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 && (
<View
pointerEvents="none"
style={[styles.pAbsolute, styles.t0, styles.r0, styles.b0, styles.l0, styles.appBG, styles.overflowHidden, styles.zIndex10, styles.justifyContentEnd, styles.pb4]}
>
<ReportActionsSkeletonView />
</View>
)}
</ReportActionsListPaddingView>
</>
);
Expand Down
46 changes: 42 additions & 4 deletions tests/ui/ReportActionsListTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof React>('react');
return {
LegendList: jest.fn(() => {
LegendList: jest.fn(({onLoad}: {onLoad?: () => void}) => {
reactModule.useEffect(() => {
mockLegendListMount();
if (mockShouldCallLegendListOnLoad) {
onLoad?.();
}
return () => {
mockLegendListUnmount();
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -300,6 +305,7 @@ describe('ReportActionsList (body)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHasOnceLoadedReportActions = true;
mockShouldCallLegendListOnLoad = true;
mockUseIsReportLoadPending.mockReturnValue(false);

mockUseCurrentUserPersonalDetails.mockReturnValue({
Expand Down Expand Up @@ -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(
<ReportActionsList
reportID={mockReport.reportID}
conciergeChat={undefined}
onLayout={jest.fn()}
/>,
);
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;
Expand Down Expand Up @@ -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({
Expand All @@ -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();
});

Expand Down