From a58aaa5faacf2ddc200a89909721a47e43ef7623 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 22:10:30 +0200 Subject: [PATCH 1/4] fix: align report action loading skeletons --- .../MoneyRequestReportView.tsx | 11 +++-- src/components/ReportActionsSkeletonCover.tsx | 30 ++++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 3 +- .../report/ReportActionsLoadingSkeleton.tsx | 7 ++- tests/ui/MoneyRequestReportViewTest.tsx | 46 ++++++++++++++++++- tests/ui/ReportActionsTest.tsx | 2 + tests/unit/ReportActionsSkeletonCoverTest.tsx | 38 +++++++++++++++ 7 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 src/components/ReportActionsSkeletonCover.tsx create mode 100644 tests/unit/ReportActionsSkeletonCoverTest.tsx diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index d411a44189a6..066762507a64 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -3,6 +3,7 @@ import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView'; +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; @@ -105,7 +106,7 @@ function InitialLoadingSkeleton({styles, onLayout}: {styles: ThemeStyles; onLayo {}} /> - + ); } @@ -217,7 +218,11 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, } if (shouldShowEmptyActionsSkeleton) { - return ; + return ( + + + + ); } if (!report) { @@ -228,7 +233,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, return ( - + {shouldDisplayReportFooter ? : null} ); diff --git a/src/components/ReportActionsSkeletonCover.tsx b/src/components/ReportActionsSkeletonCover.tsx new file mode 100644 index 000000000000..e2e1cc321fff --- /dev/null +++ b/src/components/ReportActionsSkeletonCover.tsx @@ -0,0 +1,30 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; + +import React from 'react'; +import {View} from 'react-native'; + +import ReportActionsSkeletonView from './ReportActionsSkeletonView'; + +type ReportActionsSkeletonCoverProps = { + /** The skeleton content to place at the bottom of the report viewport */ + children?: ReactNode; +}; + +/** Fills the report-actions viewport with a consistently positioned loading skeleton. */ +function ReportActionsSkeletonCover({children}: ReportActionsSkeletonCoverProps) { + const styles = useThemeStyles(); + + return ( + + {children ?? } + + ); +} + +export default ReportActionsSkeletonCover; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index b247b9c00e0b..9100bb8b6398 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,6 +1,7 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; @@ -449,7 +450,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report // is cleared mid-session while the latch keeps the content mounted. if (!report) { - return ; + return ; } return ( diff --git a/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx index 2fc926b894d0..41959067fdb8 100644 --- a/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx +++ b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx @@ -1,3 +1,4 @@ +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; @@ -28,7 +29,11 @@ type ReportActionsLoadingSkeletonProps = { function ReportActionsLoadingSkeleton({reportID, skeletonName, shouldAnimate = true, shouldMarkOpenReportEnd = true}: ReportActionsLoadingSkeletonProps) { useCancelSendMessageSpanOnSkeleton(reportID, skeletonName); useMarkOpenReportEndOnSkeleton(reportID, shouldMarkOpenReportEnd); - return ; + return ( + + + + ); } ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton'; diff --git a/tests/ui/MoneyRequestReportViewTest.tsx b/tests/ui/MoneyRequestReportViewTest.tsx index f0dc787734b5..00723a42cc92 100644 --- a/tests/ui/MoneyRequestReportViewTest.tsx +++ b/tests/ui/MoneyRequestReportViewTest.tsx @@ -1,9 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ -import {render} from '@testing-library/react-native'; +import {render, screen} from '@testing-library/react-native'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; import MoneyRequestReportView from '@components/MoneyRequestReportView/MoneyRequestReportView'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -34,6 +36,10 @@ jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/usePaginatedReportActions', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); +jest.mock('@hooks/useInFlightRequests', () => ({ + useIsAppLoadPending: jest.fn(), + useIsReportLoadPending: jest.fn(), +})); // useThemeStyles throws without a ; return a proxy that yields an empty style object // for any key so the (mostly-mocked) tree renders without wiring up the full provider stack. @@ -54,6 +60,8 @@ jest.mock('@components/MoneyReportHeader', () => jest.fn(() => null)); jest.mock('@components/MoneyRequestHeader', () => jest.fn(() => null)); jest.mock('@components/CollapsibleHeaderOnKeyboard', () => jest.fn(() => null)); jest.mock('@components/ReportActionItem/MoneyRequestReceiptView', () => jest.fn(() => null)); +jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null)); +jest.mock('@components/ReportHeaderSkeletonView', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportFooter', () => jest.fn(() => null)); jest.mock('@components/OfflineWithFeedback', () => { const reactModule = jest.requireActual('react'); @@ -61,12 +69,15 @@ jest.mock('@components/OfflineWithFeedback', () => { }); const mockUseNetwork = useNetwork as jest.MockedFunction; +const mockUseIsAppLoadPending = jest.mocked(useIsAppLoadPending); +const mockUseIsReportLoadPending = jest.mocked(useIsReportLoadPending); const mockUseOnyx = useOnyx as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; const mockUsePaginatedReportActions = usePaginatedReportActions as jest.MockedFunction; const mockUseReportTransactionsCollection = useReportTransactionsCollection as jest.MockedFunction; const mockMoneyRequestReportActionsList = MoneyRequestReportActionsList as jest.MockedFunction; const mockReportActionsListBody = ReportActionsList as jest.MockedFunction; +const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView); const mockUserTypingEventListener = UserTypingEventListener as jest.MockedFunction; const defaultPaginatedReportActionsResult: ReturnType = { @@ -134,6 +145,8 @@ describe('MoneyRequestReportView', () => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsAppLoadPending.mockReturnValue(false); + mockUseIsReportLoadPending.mockReturnValue(false); mockUsePaginatedReportActions.mockReturnValue(defaultPaginatedReportActionsResult); mockUseReportTransactionsCollection.mockReturnValue({}); mockUseResponsiveLayout.mockReturnValue({ @@ -176,6 +189,37 @@ describe('MoneyRequestReportView', () => { expect(MoneyRequestReportUtils.shouldWaitForTransactions).toHaveBeenLastCalledWith(mockReport, [], mockReportLoadingState, false, false); }); + it('uses the bottom-padded cover while the report is waiting for transactions', () => { + jest.spyOn(MoneyRequestReportUtils, 'shouldWaitForTransactions').mockReturnValue(true); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + + it('uses a static bottom-padded cover while report actions are empty', () => { + jest.spyOn(ReportActionsUtils, 'getFilteredReportActionsForReportView').mockReturnValue([]); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + + it('uses the bottom-padded cover while the app is loading', () => { + mockUseIsAppLoadPending.mockReturnValue(true); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + it('mounts the chat list body and the typing listener (not the table view) for a transaction-thread report', () => { const onLayout = jest.fn(); diff --git a/tests/ui/ReportActionsTest.tsx b/tests/ui/ReportActionsTest.tsx index 18f7f63e2496..6099444f6a52 100644 --- a/tests/ui/ReportActionsTest.tsx +++ b/tests/ui/ReportActionsTest.tsx @@ -149,6 +149,7 @@ describe('ReportActions (orchestrator)', () => { render(); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); @@ -159,6 +160,7 @@ describe('ReportActions (orchestrator)', () => { render(); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); diff --git a/tests/unit/ReportActionsSkeletonCoverTest.tsx b/tests/unit/ReportActionsSkeletonCoverTest.tsx new file mode 100644 index 000000000000..959708cf16a6 --- /dev/null +++ b/tests/unit/ReportActionsSkeletonCoverTest.tsx @@ -0,0 +1,38 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import React from 'react'; + +jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null)); + +const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView); + +describe('ReportActionsSkeletonCover', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fills, clips, and bottom-aligns the default report-actions skeleton', () => { + render(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ + flex: 1, + overflow: 'hidden', + justifyContent: 'flex-end', + paddingBottom: 16, + }); + expect(mockReportActionsSkeletonView).toHaveBeenCalledTimes(1); + }); + + it('accepts a static report-actions skeleton as its content', () => { + render( + + + , + ); + + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + }); +}); From c02ce9b2ff05f5eed64a0bbdaca5246fa7a6cc95 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 11 Sep 2026 12:41:17 +0200 Subject: [PATCH 2/4] refactor: compose report action skeleton covers --- .../MoneyRequestReportView.tsx | 13 +++------ src/components/ReportActionsSkeletonCover.tsx | 28 +++++++++++++++---- src/pages/inbox/report/ReportActionsList.tsx | 4 +-- .../report/ReportActionsLoadingSkeleton.tsx | 11 +++----- tests/ui/MoneyRequestReportViewTest.tsx | 2 ++ tests/unit/ReportActionsSkeletonCoverTest.tsx | 17 ++++++----- 6 files changed, 43 insertions(+), 32 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 066762507a64..c40550bf542c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -3,8 +3,7 @@ import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView'; -import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -106,7 +105,7 @@ function InitialLoadingSkeleton({styles, onLayout}: {styles: ThemeStyles; onLayo {}} /> - + ); } @@ -218,11 +217,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, } if (shouldShowEmptyActionsSkeleton) { - return ( - - - - ); + return ; } if (!report) { @@ -233,7 +228,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, return ( - + {shouldDisplayReportFooter ? : null} ); diff --git a/src/components/ReportActionsSkeletonCover.tsx b/src/components/ReportActionsSkeletonCover.tsx index e2e1cc321fff..c20632a781ef 100644 --- a/src/components/ReportActionsSkeletonCover.tsx +++ b/src/components/ReportActionsSkeletonCover.tsx @@ -7,13 +7,30 @@ import {View} from 'react-native'; import ReportActionsSkeletonView from './ReportActionsSkeletonView'; -type ReportActionsSkeletonCoverProps = { +type ReportActionsSkeletonContainerProps = { /** The skeleton content to place at the bottom of the report viewport */ - children?: ReactNode; + children: ReactNode; }; -/** Fills the report-actions viewport with a consistently positioned loading skeleton. */ -function ReportActionsSkeletonCover({children}: ReportActionsSkeletonCoverProps) { +/** Fills the report-actions viewport with a consistently positioned static loading skeleton. */ +function ReportActionsSkeletonCover() { + return ( + + + + ); +} + +/** Fills the report-actions viewport with a consistently positioned animated loading skeleton. */ +function ReportActionsAnimatedSkeletonCover() { + return ( + + + + ); +} + +function ReportActionsSkeletonContainer({children}: ReportActionsSkeletonContainerProps) { const styles = useThemeStyles(); return ( @@ -22,9 +39,10 @@ function ReportActionsSkeletonCover({children}: ReportActionsSkeletonCoverProps) testID="ReportActionsSkeletonCover" style={[styles.flex1, styles.appBG, styles.overflowHidden, styles.justifyContentEnd, styles.pb4]} > - {children ?? } + {children} ); } +export {ReportActionsAnimatedSkeletonCover}; export default ReportActionsSkeletonCover; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 9100bb8b6398..94ab925a4b47 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,7 +1,7 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; -import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; +import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; @@ -450,7 +450,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report // is cleared mid-session while the latch keeps the content mounted. if (!report) { - return ; + return ; } return ( diff --git a/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx index 41959067fdb8..cda1d6afc91b 100644 --- a/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx +++ b/src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx @@ -1,5 +1,4 @@ -import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; import type {SkeletonName} from '@hooks/useCancelSendMessageSpanOnSkeleton'; @@ -29,11 +28,9 @@ type ReportActionsLoadingSkeletonProps = { function ReportActionsLoadingSkeleton({reportID, skeletonName, shouldAnimate = true, shouldMarkOpenReportEnd = true}: ReportActionsLoadingSkeletonProps) { useCancelSendMessageSpanOnSkeleton(reportID, skeletonName); useMarkOpenReportEndOnSkeleton(reportID, shouldMarkOpenReportEnd); - return ( - - - - ); + const SkeletonCover = shouldAnimate ? ReportActionsAnimatedSkeletonCover : ReportActionsSkeletonCover; + + return ; } ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton'; diff --git a/tests/ui/MoneyRequestReportViewTest.tsx b/tests/ui/MoneyRequestReportViewTest.tsx index 00723a42cc92..e1f3b9ab6a98 100644 --- a/tests/ui/MoneyRequestReportViewTest.tsx +++ b/tests/ui/MoneyRequestReportViewTest.tsx @@ -195,6 +195,7 @@ describe('MoneyRequestReportView', () => { renderMoneyRequestReportView(jest.fn()); expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true})); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); }); @@ -216,6 +217,7 @@ describe('MoneyRequestReportView', () => { renderMoneyRequestReportView(jest.fn()); expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true})); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); }); diff --git a/tests/unit/ReportActionsSkeletonCoverTest.tsx b/tests/unit/ReportActionsSkeletonCoverTest.tsx index 959708cf16a6..0c73c8083ed1 100644 --- a/tests/unit/ReportActionsSkeletonCoverTest.tsx +++ b/tests/unit/ReportActionsSkeletonCoverTest.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react-native'; -import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; +import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import React from 'react'; @@ -14,7 +14,7 @@ describe('ReportActionsSkeletonCover', () => { jest.clearAllMocks(); }); - it('fills, clips, and bottom-aligns the default report-actions skeleton', () => { + it('fills, clips, and bottom-aligns the static report-actions skeleton', () => { render(); expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ @@ -24,15 +24,14 @@ describe('ReportActionsSkeletonCover', () => { paddingBottom: 16, }); expect(mockReportActionsSkeletonView).toHaveBeenCalledTimes(1); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); }); - it('accepts a static report-actions skeleton as its content', () => { - render( - - - , - ); + it('fills the report-actions viewport with an animated skeleton', () => { + render(); - expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView).toHaveBeenCalledTimes(1); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true})); }); }); From 32258113e201e38d2878683e672e8792525e4a76 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 11 Sep 2026 12:52:10 +0200 Subject: [PATCH 3/4] refactor: remove report view memoization --- .../MoneyRequestReportView.tsx | 79 ++++++++----------- 1 file changed, 32 insertions(+), 47 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index c40550bf542c..d3e5e487badd 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -44,7 +44,7 @@ import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {PortalHost} from '@gorhom/portal'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; // We use Animated for all functionality related to wide RHP to make it easier // to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. // eslint-disable-next-line no-restricted-imports @@ -126,34 +126,23 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const {reportActions: unfilteredReportActions} = usePaginatedReportActions(reportID); - const reportActions = useMemo(() => { - return getFilteredReportActionsForReportView(unfilteredReportActions); - }, [unfilteredReportActions]); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const reportTransactions = useReportTransactionsCollection(reportID); - const transactions = useMemo(() => getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true), [reportTransactions, reportActions, isOffline]); + const transactions = getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true); - const visibleTransactions = useMemo(() => { - if (isOffline) { - return transactions; - } - - // When there are no pending delete transactions, which is most of the time, we can return the same transactions keeping the same reference avoiding extra work - const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - if (!hasPendingDelete) { - return transactions; - } - - return transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - }, [transactions, isOffline]); + // When there are no pending delete transactions, which is most of the time, return the same transactions to keep the same reference and avoid extra work. + const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const visibleTransactions = + isOffline || !hasPendingDelete ? transactions : transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const reportErrors = visibleTransactions.length === 1 && visibleTransactions.at(0)?.errors ? undefined : allReportErrors; const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const isReportLoadPending = useIsReportLoadPending(reportID); - const dismissReportCreationError = useCallback(() => { + const dismissReportCreationError = () => { goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)}); - }, [reportID]); + }; // Special case handling a report that is a transaction thread // If true we will use the standard `ReportActionsList` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList` @@ -171,33 +160,29 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const shouldShowWideRHPReceipt = visibleTransactions.length === 1 && !isSmallScreenWidth && !!transactionThreadReport; - const reportHeaderView = useMemo( - () => - isTransactionThreadView ? ( - { - if (!backToRoute) { - goBackFromSearchMoneyRequest(); - return; - } - Navigation.goBack(backToRoute); - }} - /> - ) : ( - { - if (!backToRoute) { - goBackFromSearchMoneyRequest(); - return; - } - Navigation.goBack(backToRoute); - }} - /> - ), - [backToRoute, isTransactionThreadView, report?.reportID], + const reportHeaderView = isTransactionThreadView ? ( + { + if (!backToRoute) { + goBackFromSearchMoneyRequest(); + return; + } + Navigation.goBack(backToRoute); + }} + /> + ) : ( + { + if (!backToRoute) { + goBackFromSearchMoneyRequest(); + return; + } + Navigation.goBack(backToRoute); + }} + /> ); // We need to cancel telemetry span when user leaves the screen before full report data is loaded From 38b59e765ea089912ce188e398303925fd88f715 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 16 Sep 2026 21:12:32 +0200 Subject: [PATCH 4/4] fix: fill report view with app load skeleton --- .../MoneyRequestReportView/MoneyRequestReportView.tsx | 3 +-- tests/ui/MoneyRequestReportViewTest.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 03a66b5d3491..ebef8dc41714 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -215,8 +215,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, if (shouldShowAppLoadSkeleton) { return ( - - + {shouldDisplayReportFooter ? : null} ); diff --git a/tests/ui/MoneyRequestReportViewTest.tsx b/tests/ui/MoneyRequestReportViewTest.tsx index e1f3b9ab6a98..4b8a39aa05f4 100644 --- a/tests/ui/MoneyRequestReportViewTest.tsx +++ b/tests/ui/MoneyRequestReportViewTest.tsx @@ -4,6 +4,7 @@ import {render, screen} from '@testing-library/react-native'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; import MoneyRequestReportView from '@components/MoneyRequestReportView/MoneyRequestReportView'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; @@ -41,10 +42,10 @@ jest.mock('@hooks/useInFlightRequests', () => ({ useIsReportLoadPending: jest.fn(), })); -// useThemeStyles throws without a ; return a proxy that yields an empty style object -// for any key so the (mostly-mocked) tree renders without wiring up the full provider stack. +// useThemeStyles throws without a ; return empty styles for most keys and +// a fixed header height for the app-loading layout assertion. jest.mock('@hooks/useThemeStyles', () => { - const styleProxy = new Proxy({}, {get: () => ({})}); + const styleProxy = new Proxy({}, {get: (_target, key) => (key === 'headerBarHeight' ? {height: 80} : {})}); return jest.fn(() => styleProxy); }); @@ -217,6 +218,7 @@ describe('MoneyRequestReportView', () => { renderMoneyRequestReportView(jest.fn()); expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(screen.UNSAFE_getByType(ReportHeaderSkeletonView).parent).toHaveStyle({height: 80}); expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true})); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled();