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
7 changes: 2 additions & 5 deletions src/components/MoneyReportHeaderNextStep.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import {useIsReportLoadPending} from '@hooks/useInFlightRequests';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import useOptimisticNextStep from '@hooks/useOptimisticNextStep';

import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';

import ONYXKEYS from '@src/ONYXKEYS';

import React from 'react';

import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar';
Expand All @@ -20,8 +18,7 @@ type MoneyReportHeaderNextStepProps = {
*/
function MoneyReportHeaderNextStep({reportID}: MoneyReportHeaderNextStepProps) {
const {isOffline} = useNetwork();
const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`);
const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions;
const isLoadingInitialReportActions = useIsReportLoadPending(reportID);
const optimisticNextStep = useOptimisticNextStep(reportID);

const showNextStepBar = !!optimisticNextStep && (('message' in optimisticNextStep && !!optimisticNextStep.message?.length) || 'messageKey' in optimisticNextStep);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestRe
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView';

import {useIsAppLoadPending} from '@hooks/useInFlightRequests';
import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests';
import useMarkOpenReportEndOnSkeleton from '@hooks/useMarkOpenReportEndOnSkeleton';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
Expand Down Expand Up @@ -153,7 +153,7 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport
const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID);
const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs);

const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions;
const isLoadingInitialReportActions = useIsReportLoadPending(reportID);
Comment thread
BartekObudzinski marked this conversation as resolved.
const dismissReportCreationError = useCallback(() => {
goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)});
}, [reportID]);
Expand Down
36 changes: 31 additions & 5 deletions src/hooks/useInFlightRequests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {WRITE_COMMANDS} from '@libs/API/types';
import type {WriteCommand} from '@libs/API/types';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';

import ONYXKEYS from '@src/ONYXKEYS';
import {isLoadingInitialReportActionsSelector} from '@src/selectors/ReportMetaData';
import type {AnyRequest} from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';
Expand Down Expand Up @@ -118,6 +120,9 @@ function useIsPendingInternal(group: PendingRequestGroup, scopeKey?: string | nu
// accompanied by a dep change that re-renders every consumer, so no consumer can strand a stale read.
let hasObservedOpenAppFlushPending = false;

// Keep this outside the hook so a new consumer can see a report whose deferred updates are still pending.
const reportIDsWithPendingOpenReportFlush = new Set<string>();

/** Whether an OpenApp request or its deferred Onyx updates are pending. */
function useIsAppLoadPending(): boolean {
const hasPendingOpenApp = useIsPendingInternal('appLoad');
Expand All @@ -136,13 +141,34 @@ function useIsAppLoadPending(): boolean {
}

/**
* Whether an OpenReport request for the given report is currently in the queue.
* Whether an OpenReport request or its deferred Onyx updates are pending for this report.
*
* `undefined` returns false, so callers can pass an optional reportID without a fallback value.
*
* Do not call this inside list-item render paths (e.g. per row in a list): every call opens two Onyx
* subscriptions. Lift it to the screen level and pass the result down instead.
* Do not use this hook in list rows. Each call creates three Onyx subscriptions.
* Call it at screen level and pass the result down.
*/
function useIsReportLoadPending(reportID: string): boolean {
return useIsPendingInternal('reportLoad', reportID);
function useIsReportLoadPending(reportID: string | undefined): boolean {
const hasPendingRequest = useIsPendingInternal('reportLoad', reportID);
const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(reportID)}`, {
selector: isLoadingInitialReportActionsSelector,
});

// Track the loading flag only after this session sees a matching OpenReport request, so stale flags are ignored.
// Keep tracking until the deferred updates clear the flag.
useEffect(() => {
if (!reportID) {
return;
}

if (hasPendingRequest) {
reportIDsWithPendingOpenReportFlush.add(reportID);
} else if (isLoadingInitialReportActions !== true) {
reportIDsWithPendingOpenReportFlush.delete(reportID);
}
}, [hasPendingRequest, isLoadingInitialReportActions, reportID]);

return hasPendingRequest || (!!reportID && reportIDsWithPendingOpenReportFlush.has(reportID) && isLoadingInitialReportActions === true);
}

/** Whether any request relevant to the top-of-screen loading bar is currently in the queue. */
Expand Down
6 changes: 2 additions & 4 deletions src/pages/inbox/report/ReportFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import BlockedReportFooter from '@components/BlockedReportFooter';
import OfflineIndicator from '@components/OfflineIndicator';
import SwipeableView from '@components/SwipeableView';

import {useIsReportLoadPending} from '@hooks/useInFlightRequests';
import useIsAnonymousUser from '@hooks/useIsAnonymousUser';
import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
Expand All @@ -27,7 +28,6 @@ import {

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {isLoadingInitialReportActionsSelector} from '@src/selectors/ReportMetaData';
import type * as OnyxTypes from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';
Expand Down Expand Up @@ -75,9 +75,7 @@ function ReportFooter() {
selector: policyRoleSelector,
});
const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`);
const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`, {
selector: isLoadingInitialReportActionsSelector,
});
const isLoadingInitialReportActions = useIsReportLoadPending(reportIDFromRoute);

const isUserPolicyAdmin = policyRole === CONST.POLICY.ROLE.ADMIN;
const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived);
Expand Down
77 changes: 73 additions & 4 deletions tests/unit/useInFlightRequestsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ describe('useInFlightRequests', () => {

beforeEach(async () => {
await Onyx.clear().then(waitForBatchedUpdates);
// useIsAppLoadPending keeps a process-session latch (module-level) that survives Onyx.clear.
// Render it once against the cleared state so its reset effect runs, isolating each test.
const {unmount} = renderHook(() => useIsAppLoadPending());
// Module-level state survives `Onyx.clear()`. Mount both hooks once so their effects reset it before each test.
const {unmount: unmountAppLoad} = renderHook(() => useIsAppLoadPending());
const {unmount: unmountReportLoad} = renderHook(() => useIsReportLoadPending('1234'));
await act(() => waitForBatchedUpdates());
unmount();
unmountAppLoad();
unmountReportLoad();
});

describe('useIsAppLoadPending', () => {
Expand Down Expand Up @@ -157,6 +158,74 @@ describe('useInFlightRequests', () => {
await act(() => waitForBatchedUpdates());
expect(nonMatching.current).toBe(false);
});

it('returns false for an undefined reportID even when an OpenReport is queued', async () => {
await setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_REPORT, {reportID: '1234'})]);
const {result} = renderHook(() => useIsReportLoadPending(undefined));
await act(() => waitForBatchedUpdates());
expect(result.current).toBe(false);
});

it('waits for the terminal loading update only after observing a matching OpenReport request', async () => {
const loadingStateKey = `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}1234` as const;
await Onyx.merge(loadingStateKey, {isLoadingInitialReportActions: true}).then(waitForBatchedUpdates);

const {result} = renderHook(() => useIsReportLoadPending('1234'));
await act(() => waitForBatchedUpdates());

expect(result.current).toBe(false);

await act(() => setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_REPORT, {reportID: '1234'})]));
await waitFor(() => expect(result.current).toBe(true));

await act(() => setPersistedRequests([]));
await waitFor(() => expect(result.current).toBe(true));

await act(() => Onyx.merge(loadingStateKey, {isLoadingInitialReportActions: false}).then(waitForBatchedUpdates));
await waitFor(() => expect(result.current).toBe(false));
});

it('shares the observed loading lifecycle with a consumer that mounts after the request leaves the queue', async () => {
const loadingStateKey = `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}1234` as const;
await Onyx.merge(loadingStateKey, {isLoadingInitialReportActions: true}).then(waitForBatchedUpdates);
await setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_REPORT, {reportID: '1234'})]);

const {result: firstConsumer} = renderHook(() => useIsReportLoadPending('1234'));
await waitFor(() => expect(firstConsumer.current).toBe(true));

await act(() => setPersistedRequests([]));
await waitFor(() => expect(firstConsumer.current).toBe(true));

const {result: lateConsumer} = renderHook(() => useIsReportLoadPending('1234'));
await act(() => waitForBatchedUpdates());
expect(lateConsumer.current).toBe(true);

await act(() => Onyx.merge(loadingStateKey, {isLoadingInitialReportActions: false}).then(waitForBatchedUpdates));
await waitFor(() => {
expect(firstConsumer.current).toBe(false);
expect(lateConsumer.current).toBe(false);
});
});

it('does not carry an armed lifecycle to a new reportID with a stranded loading flag', async () => {
const firstLoadingStateKey = `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}1234` as const;
const secondLoadingStateKey = `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}5678` as const;
await Promise.all([Onyx.merge(firstLoadingStateKey, {isLoadingInitialReportActions: true}), Onyx.merge(secondLoadingStateKey, {isLoadingInitialReportActions: true})]).then(
waitForBatchedUpdates,
);
await setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_REPORT, {reportID: '1234'})]);

const {result, rerender} = renderHook(({reportID}: {reportID: string}) => useIsReportLoadPending(reportID), {
initialProps: {reportID: '1234'},
});
await waitFor(() => expect(result.current).toBe(true));

await act(() => setPersistedRequests([]));
await waitFor(() => expect(result.current).toBe(true));

rerender({reportID: '5678'});
expect(result.current).toBe(false);
});
});

describe('useIsLoadingBarPending', () => {
Expand Down
Loading