From f54be55f46c24e0f4cec76d9336007c7dc1b68bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Fri, 17 Jul 2026 15:12:11 +0200 Subject: [PATCH 1/5] feat: derive report-load skeleton gates from the request queue --- src/components/MoneyReportHeaderNextStep.tsx | 10 ++++----- .../MoneyRequestReportView.tsx | 6 ++++- src/hooks/useInFlightRequests.ts | 5 ++++- src/pages/inbox/report/ReportFooter.tsx | 9 ++++---- tests/unit/useInFlightRequestsTest.ts | 22 +++++++++++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/components/MoneyReportHeaderNextStep.tsx b/src/components/MoneyReportHeaderNextStep.tsx index 651a12c909a0..c3f5ad4fba15 100644 --- a/src/components/MoneyReportHeaderNextStep.tsx +++ b/src/components/MoneyReportHeaderNextStep.tsx @@ -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'; @@ -20,8 +18,10 @@ type MoneyReportHeaderNextStepProps = { */ function MoneyReportHeaderNextStep({reportID}: MoneyReportHeaderNextStepProps) { const {isOffline} = useNetwork(); - const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); - const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; + // Whether the report's initial actions are still loading, derived from the request queue (an in-flight + // OpenReport for this report) rather than the stored report-metadata flag, so the queue stays the single + // source of truth for load state. + const isLoadingInitialReportActions = useIsReportLoadPending(reportID); const optimisticNextStep = useOptimisticNextStep(reportID); const showNextStepBar = !!optimisticNextStep && (('message' in optimisticNextStep && !!optimisticNextStep.message?.length) || 'messageKey' in optimisticNextStep); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 8013b3e8884b..ab25f4c03e54 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -6,6 +6,7 @@ import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestRe import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; +import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useMarkOpenReportEndOnSkeleton from '@hooks/useMarkOpenReportEndOnSkeleton'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -152,7 +153,10 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); - const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; + // Whether the report's initial actions are still loading, derived from the request queue (an in-flight + // OpenReport for this report) rather than the stored report-metadata flag, so the queue stays the single + // source of truth for load state. `reportLoadingState` is still read for the transaction-wait check below. + const isLoadingInitialReportActions = useIsReportLoadPending(reportID); const dismissReportCreationError = useCallback(() => { goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)}); }, [reportID]); diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index b32d77c91b69..7d0210781035 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -107,10 +107,13 @@ function useIsAppLoadPending(): boolean { /** * Whether an OpenReport request for the given report is currently in the queue. * + * Accepts `undefined` so callers with an optional reportID don't have to default the ID to a sentinel + * value: an undefined scope key never matches a real OpenReport request, so it reads as "not loading". + * * 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. */ -function useIsReportLoadPending(reportID: string): boolean { +function useIsReportLoadPending(reportID: string | undefined): boolean { return useIsPendingInternal('reportLoad', reportID); } diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 148e628fd918..6ca0e01de5a8 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -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'; @@ -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'; @@ -75,9 +75,10 @@ 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, - }); + // Whether the report's initial actions are still loading, derived from the request queue (an in-flight + // OpenReport for this report) rather than the stored report-metadata flag, so the queue stays the single + // source of truth for load state. + const isLoadingInitialReportActions = useIsReportLoadPending(reportIDFromRoute); const isUserPolicyAdmin = policyRole === CONST.POLICY.ROLE.ADMIN; const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); diff --git a/tests/unit/useInFlightRequestsTest.ts b/tests/unit/useInFlightRequestsTest.ts index 73230d121f2d..19423807d1a6 100644 --- a/tests/unit/useInFlightRequestsTest.ts +++ b/tests/unit/useInFlightRequestsTest.ts @@ -112,6 +112,28 @@ describe('useInFlightRequests', () => { await act(() => waitForBatchedUpdates()); expect(nonMatching.current).toBe(false); }); + + // Consumers with a possibly-undefined reportID pass it straight through. An undefined scope key must + // never match a real OpenReport request, so an absent report reads as "not loading". + 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); + }); + + // Mirrors the healthy load: skeleton on while OpenReport is in the queue, off once it clears. + it('updates reactively as the OpenReport request enters and leaves the queue', async () => { + 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(false)); + }); }); describe('useIsLoadingBarPending', () => { From 701590b4ee3a06ba7a2dac4f78f027d7a5bc5230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 21 Jul 2026 09:15:19 +0200 Subject: [PATCH 2/5] fix: wait for OpenReport updates to flush Co-authored-by: Cursor --- src/components/MoneyReportHeaderNextStep.tsx | 5 ++- .../MoneyRequestReportView.tsx | 5 ++- src/hooks/useInFlightRequests.ts | 27 ++++++++++++++-- src/pages/inbox/report/ReportFooter.tsx | 5 ++- tests/unit/useInFlightRequestsTest.ts | 31 +++++++++++++++++-- 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/components/MoneyReportHeaderNextStep.tsx b/src/components/MoneyReportHeaderNextStep.tsx index c3f5ad4fba15..585c8b6a5b01 100644 --- a/src/components/MoneyReportHeaderNextStep.tsx +++ b/src/components/MoneyReportHeaderNextStep.tsx @@ -18,9 +18,8 @@ type MoneyReportHeaderNextStepProps = { */ function MoneyReportHeaderNextStep({reportID}: MoneyReportHeaderNextStepProps) { const {isOffline} = useNetwork(); - // Whether the report's initial actions are still loading, derived from the request queue (an in-flight - // OpenReport for this report) rather than the stored report-metadata flag, so the queue stays the single - // source of truth for load state. + // The matching OpenReport request starts this lifecycle, and its terminal loading update ends it after + // deferred response data is applied. const isLoadingInitialReportActions = useIsReportLoadPending(reportID); const optimisticNextStep = useOptimisticNextStep(reportID); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index ab25f4c03e54..d9735d324341 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -153,9 +153,8 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); - // Whether the report's initial actions are still loading, derived from the request queue (an in-flight - // OpenReport for this report) rather than the stored report-metadata flag, so the queue stays the single - // source of truth for load state. `reportLoadingState` is still read for the transaction-wait check below. + // The matching OpenReport request starts this lifecycle, and its terminal loading update ends it after + // deferred response data is applied. `reportLoadingState` is still read for the transaction-wait check below. const isLoadingInitialReportActions = useIsReportLoadPending(reportID); const dismissReportCreationError = useCallback(() => { goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)}); diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index 7d0210781035..22eb2987cd81 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -1,11 +1,15 @@ 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'; +import {useState} from 'react'; + import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -110,11 +114,30 @@ function useIsAppLoadPending(): boolean { * Accepts `undefined` so callers with an optional reportID don't have to default the ID to a sentinel * value: an undefined scope key never matches a real OpenReport request, so it reads as "not loading". * - * Do not call this inside list-item render paths (e.g. per row in a list): every call opens two Onyx + * Do not call this inside list-item render paths (e.g. per row in a list): every call opens three Onyx * subscriptions. Lift it to the screen level and pass the result down instead. */ function useIsReportLoadPending(reportID: string | undefined): boolean { - return useIsPendingInternal('reportLoad', reportID); + const hasPendingRequest = useIsPendingInternal('reportLoad', reportID); + const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(reportID)}`, { + selector: isLoadingInitialReportActionsSelector, + }); + + // The queue arms this lifecycle, so a loading flag stranded by a previous process cannot gate by itself. + // Once armed, the terminal flag keeps consumers gated until deferred response updates are flushed. + const [trackedReportID, setTrackedReportID] = useState(reportID); + const [hasObservedPendingRequest, setHasObservedPendingRequest] = useState(false); + const isTrackedReport = trackedReportID === reportID; + if (!isTrackedReport) { + setTrackedReportID(reportID); + setHasObservedPendingRequest(hasPendingRequest); + } else if (hasPendingRequest && !hasObservedPendingRequest) { + setHasObservedPendingRequest(true); + } else if (!hasPendingRequest && isLoadingInitialReportActions !== true && hasObservedPendingRequest) { + setHasObservedPendingRequest(false); + } + + return hasPendingRequest || (isTrackedReport && hasObservedPendingRequest && isLoadingInitialReportActions === true); } /** Whether any request relevant to the top-of-screen loading bar is currently in the queue. */ diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 6ca0e01de5a8..3999b90474cb 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -75,9 +75,8 @@ function ReportFooter() { selector: policyRoleSelector, }); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`); - // Whether the report's initial actions are still loading, derived from the request queue (an in-flight - // OpenReport for this report) rather than the stored report-metadata flag, so the queue stays the single - // source of truth for load state. + // The matching OpenReport request starts this lifecycle, and its terminal loading update ends it after + // deferred response data is applied. const isLoadingInitialReportActions = useIsReportLoadPending(reportIDFromRoute); const isUserPolicyAdmin = policyRole === CONST.POLICY.ROLE.ADMIN; diff --git a/tests/unit/useInFlightRequestsTest.ts b/tests/unit/useInFlightRequestsTest.ts index 19423807d1a6..0fcc2033af1a 100644 --- a/tests/unit/useInFlightRequestsTest.ts +++ b/tests/unit/useInFlightRequestsTest.ts @@ -122,18 +122,45 @@ describe('useInFlightRequests', () => { expect(result.current).toBe(false); }); - // Mirrors the healthy load: skeleton on while OpenReport is in the queue, off once it clears. - it('updates reactively as the OpenReport request enters and leaves the queue', async () => { + 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()); + + // A loading flag left by a previous process must not gate a report before this process observes its request. 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('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', () => { From 788f7ff12e582eae14ca7b9b26e97d60dafdbc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Thu, 23 Jul 2026 09:49:10 +0200 Subject: [PATCH 3/5] docs: centralize report loading lifecycle --- src/components/MoneyReportHeaderNextStep.tsx | 2 -- .../MoneyRequestReportView/MoneyRequestReportView.tsx | 2 -- src/hooks/useInFlightRequests.ts | 2 +- src/pages/inbox/report/ReportFooter.tsx | 2 -- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/MoneyReportHeaderNextStep.tsx b/src/components/MoneyReportHeaderNextStep.tsx index 585c8b6a5b01..585c23cbb4e0 100644 --- a/src/components/MoneyReportHeaderNextStep.tsx +++ b/src/components/MoneyReportHeaderNextStep.tsx @@ -18,8 +18,6 @@ type MoneyReportHeaderNextStepProps = { */ function MoneyReportHeaderNextStep({reportID}: MoneyReportHeaderNextStepProps) { const {isOffline} = useNetwork(); - // The matching OpenReport request starts this lifecycle, and its terminal loading update ends it after - // deferred response data is applied. const isLoadingInitialReportActions = useIsReportLoadPending(reportID); const optimisticNextStep = useOptimisticNextStep(reportID); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index b01afc5e8904..d26f6922876f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -153,8 +153,6 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); - // The matching OpenReport request starts this lifecycle, and its terminal loading update ends it after - // deferred response data is applied. `reportLoadingState` is still read for the transaction-wait check below. const isLoadingInitialReportActions = useIsReportLoadPending(reportID); const dismissReportCreationError = useCallback(() => { goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)}); diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index 2b8d5b7d2ce8..586f9a65a797 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -138,7 +138,7 @@ 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 the given report. * * Accepts `undefined` so callers with an optional reportID don't have to default the ID to a sentinel * value: an undefined scope key never matches a real OpenReport request, so it reads as "not loading". diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 3999b90474cb..b6d8355f0f4e 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -75,8 +75,6 @@ function ReportFooter() { selector: policyRoleSelector, }); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`); - // The matching OpenReport request starts this lifecycle, and its terminal loading update ends it after - // deferred response data is applied. const isLoadingInitialReportActions = useIsReportLoadPending(reportIDFromRoute); const isUserPolicyAdmin = policyRole === CONST.POLICY.ROLE.ADMIN; From e7a9b999d19529b854261323a45bed2d18c4930e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Thu, 23 Jul 2026 10:09:26 +0200 Subject: [PATCH 4/5] fix: share report loading lifecycle state --- src/hooks/useInFlightRequests.ts | 31 ++++++++++++++------------ tests/unit/useInFlightRequestsTest.ts | 32 +++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index 586f9a65a797..bd64c7e2929c 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -8,7 +8,7 @@ import type {AnyRequest} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useEffect, useState} from 'react'; +import {useEffect} from 'react'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -120,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; +// Shared across hook instances so consumers mounting during a deferred flush observe the same lifecycle. +const reportIDsWithPendingOpenReportFlush = new Set(); + /** Whether an OpenApp request or its deferred Onyx updates are pending. */ function useIsAppLoadPending(): boolean { const hasPendingOpenApp = useIsPendingInternal('appLoad'); @@ -154,19 +157,19 @@ function useIsReportLoadPending(reportID: string | undefined): boolean { // The queue arms this lifecycle, so a loading flag stranded by a previous process cannot gate by itself. // Once armed, the terminal flag keeps consumers gated until deferred response updates are flushed. - const [trackedReportID, setTrackedReportID] = useState(reportID); - const [hasObservedPendingRequest, setHasObservedPendingRequest] = useState(false); - const isTrackedReport = trackedReportID === reportID; - if (!isTrackedReport) { - setTrackedReportID(reportID); - setHasObservedPendingRequest(hasPendingRequest); - } else if (hasPendingRequest && !hasObservedPendingRequest) { - setHasObservedPendingRequest(true); - } else if (!hasPendingRequest && isLoadingInitialReportActions !== true && hasObservedPendingRequest) { - setHasObservedPendingRequest(false); - } - - return hasPendingRequest || (isTrackedReport && hasObservedPendingRequest && isLoadingInitialReportActions === true); + 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. */ diff --git a/tests/unit/useInFlightRequestsTest.ts b/tests/unit/useInFlightRequestsTest.ts index 97ac6340a327..e9c75f312919 100644 --- a/tests/unit/useInFlightRequestsTest.ts +++ b/tests/unit/useInFlightRequestsTest.ts @@ -40,11 +40,13 @@ 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()); + // These hooks keep process-session latches that survive Onyx.clear. Render them once against the + // cleared state so their reset effects run, isolating each test. + const {unmount: unmountAppLoad} = renderHook(() => useIsAppLoadPending()); + const {unmount: unmountReportLoad} = renderHook(() => useIsReportLoadPending('1234')); await act(() => waitForBatchedUpdates()); - unmount(); + unmountAppLoad(); + unmountReportLoad(); }); describe('useIsAppLoadPending', () => { @@ -187,6 +189,28 @@ describe('useInFlightRequests', () => { 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; From c5f30cd50a4ae633e6b2b6ad9fad32fe8cdcf0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Mon, 27 Jul 2026 09:40:25 +0200 Subject: [PATCH 5/5] docs: clarify report loading comments --- src/hooks/useInFlightRequests.ts | 15 +++++++-------- tests/unit/useInFlightRequestsTest.ts | 6 +----- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index bd64c7e2929c..4bd09f4afdf8 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -120,7 +120,7 @@ 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; -// Shared across hook instances so consumers mounting during a deferred flush observe the same lifecycle. +// Keep this outside the hook so a new consumer can see a report whose deferred updates are still pending. const reportIDsWithPendingOpenReportFlush = new Set(); /** Whether an OpenApp request or its deferred Onyx updates are pending. */ @@ -141,13 +141,12 @@ function useIsAppLoadPending(): boolean { } /** - * Whether an OpenReport request or its deferred Onyx updates are pending for the given report. + * Whether an OpenReport request or its deferred Onyx updates are pending for this report. * - * Accepts `undefined` so callers with an optional reportID don't have to default the ID to a sentinel - * value: an undefined scope key never matches a real OpenReport request, so it reads as "not loading". + * `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 three 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 | undefined): boolean { const hasPendingRequest = useIsPendingInternal('reportLoad', reportID); @@ -155,8 +154,8 @@ function useIsReportLoadPending(reportID: string | undefined): boolean { selector: isLoadingInitialReportActionsSelector, }); - // The queue arms this lifecycle, so a loading flag stranded by a previous process cannot gate by itself. - // Once armed, the terminal flag keeps consumers gated until deferred response updates are flushed. + // 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; diff --git a/tests/unit/useInFlightRequestsTest.ts b/tests/unit/useInFlightRequestsTest.ts index e9c75f312919..429183ed0b87 100644 --- a/tests/unit/useInFlightRequestsTest.ts +++ b/tests/unit/useInFlightRequestsTest.ts @@ -40,8 +40,7 @@ describe('useInFlightRequests', () => { beforeEach(async () => { await Onyx.clear().then(waitForBatchedUpdates); - // These hooks keep process-session latches that survive Onyx.clear. Render them once against the - // cleared state so their reset effects run, isolating each test. + // 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()); @@ -160,8 +159,6 @@ describe('useInFlightRequests', () => { expect(nonMatching.current).toBe(false); }); - // Consumers with a possibly-undefined reportID pass it straight through. An undefined scope key must - // never match a real OpenReport request, so an absent report reads as "not loading". 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)); @@ -176,7 +173,6 @@ describe('useInFlightRequests', () => { const {result} = renderHook(() => useIsReportLoadPending('1234')); await act(() => waitForBatchedUpdates()); - // A loading flag left by a previous process must not gate a report before this process observes its request. expect(result.current).toBe(false); await act(() => setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_REPORT, {reportID: '1234'})]));