From 9dc81dff6b698ef09b4fa38190492d595ad20606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Fri, 17 Jul 2026 15:12:04 +0200 Subject: [PATCH 1/4] feat: derive app-load skeleton gates from the request queue --- .../MoneyRequestReportView.tsx | 5 +++-- .../FilterComponents/WorkspaceSelector.tsx | 5 +++-- src/hooks/useReportActionsListModel.ts | 5 +++-- ...DynamicNewReportWorkspaceSelectionPage.tsx | 7 ++++--- .../DynamicReportChangeWorkspacePage.tsx | 7 ++++--- src/pages/SetDefaultWorkspacePage.tsx | 7 ++++--- src/pages/domain/DomainsListPage.tsx | 5 +++-- src/pages/inbox/ReportActions.tsx | 5 +++-- src/pages/settings/Profile/ProfilePage.tsx | 7 ++++--- .../settings/Wallet/WalletPage/index.tsx | 7 ++++--- tests/ui/ReportActionsListTest.tsx | 20 +++++++++++-------- tests/ui/ReportActionsTest.tsx | 7 +++++-- 12 files changed, 52 insertions(+), 35 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 8013b3e8884b..14b16356a090 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 {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import useMarkOpenReportEndOnSkeleton from '@hooks/useMarkOpenReportEndOnSkeleton'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -122,7 +123,7 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport const {isSmallScreenWidth} = useResponsiveLayout(); const reportID = report?.reportID; - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const {reportPendingAction, reportErrors: allReportErrors} = getReportOfflinePendingActionAndErrors(report); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); @@ -233,7 +234,7 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport return; } - if (isLoadingApp) { + if (isAppLoadPending) { return ( diff --git a/src/components/Search/FilterComponents/WorkspaceSelector.tsx b/src/components/Search/FilterComponents/WorkspaceSelector.tsx index c3d82e2a611b..8dc4b58bdd56 100644 --- a/src/components/Search/FilterComponents/WorkspaceSelector.tsx +++ b/src/components/Search/FilterComponents/WorkspaceSelector.tsx @@ -6,6 +6,7 @@ import type {ListItem, TextInputOptions} from '@components/SelectionList/types'; import {advancedSearchPoliciesSelector, useAdvancedSearchFiltersWorkspaces} from '@hooks/useAdvancedSearchFilters'; import useDebouncedState from '@hooks/useDebouncedState'; +import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -40,7 +41,7 @@ function WorkspaceSelector({value = [], selectionListTextInputStyle, selectionLi const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const [policies = getEmptyObject>>(), policiesResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: advancedSearchPoliciesSelector}); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); // Fetch the full (unfiltered) workspace list and apply the search filter locally, so pinning is decided from the @@ -98,7 +99,7 @@ function WorkspaceSelector({value = [], selectionListTextInputStyle, selectionLi itemCount={listData.length} isSearchable={shouldShowWorkspaceSearchInput} > - {isLoadingApp && !isOffline ? ( + {isAppLoadPending && !isOffline ? ( (null); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); @@ -276,7 +277,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec ) : ( diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 54b39d3d58ee..b739a197b7a5 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -9,6 +9,7 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -73,13 +74,13 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [reportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${reportID}`); const [isChangePolicyTrainingModalDismissed = false] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {selector: changePolicyTrainingModalDismissedSelector}); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const isReportLastVisibleArchived = useReportIsArchived(report?.parentReportID); const [submitterLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(report?.ownerAccountID)}, [report?.ownerAccountID]); const [doesSubmitterPersonalDetailExist] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: doesPersonalDetailExistSelector(report?.ownerAccountID)}, [report?.ownerAccountID]); const [managerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(report?.managerID)}, [report?.managerID]); - const shouldShowLoadingIndicator = isLoadingApp && !isOffline; + const shouldShowLoadingIndicator = isAppLoadPending && !isOffline; const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const session = useSession(); @@ -213,7 +214,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace ) : ( diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 98f1003b06ce..37f006bcf192 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -7,6 +7,7 @@ import type {WorkspaceListItemType} from '@components/SelectionList/ListItem/typ import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -39,10 +40,10 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { const {translate, localeCompare} = useLocalize(); const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const shouldShowLoadingIndicator = isLoadingApp && !isOffline; + const shouldShowLoadingIndicator = isAppLoadPending && !isOffline; const session = useSession(); const selectPolicy = (selectedPolicyID?: string) => { @@ -103,7 +104,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { ) : ( diff --git a/src/pages/domain/DomainsListPage.tsx b/src/pages/domain/DomainsListPage.tsx index 998029e337b5..4d7eab5ccbea 100644 --- a/src/pages/domain/DomainsListPage.tsx +++ b/src/pages/domain/DomainsListPage.tsx @@ -6,6 +6,7 @@ import WorkspaceListLayout from '@components/WorkspaceListLayout'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; +import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -36,7 +37,7 @@ function DomainsListPage() { useDocumentTitle(translate('common.domains')); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const [allDomainErrors] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_ERRORS); @@ -49,7 +50,7 @@ function DomainsListPage() { }; const domainRows: DomainRowData[] = []; - const shouldShowLoadingIndicator = !!isLoadingApp && !isOffline; + const shouldShowLoadingIndicator = isAppLoadPending && !isOffline; if (!isEmptyObject(allDomains)) { for (const domain of Object.values(allDomains)) { diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx index 24308793174c..c782bbe15dbe 100644 --- a/src/pages/inbox/ReportActions.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -1,5 +1,6 @@ import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; +import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import useMarkOpenReportEndOnSkeleton from '@hooks/useMarkOpenReportEndOnSkeleton'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -46,7 +47,7 @@ function ReportActions() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const {reportActions} = usePaginatedReportActions(reportIDFromRoute); const allReportTransactions = useReportTransactionsCollection(reportIDFromRoute); @@ -65,7 +66,7 @@ function ReportActions() { // Concierge is excluded so the body still mounts under the app-load skeleton, seeding sessionStartTime // before content appeared. const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); - const shouldShowAppLoadSkeleton = !!isLoadingApp && !isOffline && !!report && !shouldWaitForTransactions && !shouldDisplayMoneyRequestActionsList && !isConciergeMainDM; + const shouldShowAppLoadSkeleton = isAppLoadPending && !isOffline && !!report && !shouldWaitForTransactions && !shouldDisplayMoneyRequestActionsList && !isConciergeMainDM; useMarkOpenReportEndOnSkeleton(report, shouldShowAppLoadSkeleton); diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 36bee745ac45..fb4e2809c975 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -15,6 +15,7 @@ import Section from '@components/Section'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; +import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; import {useMemoizedLazyAsset, useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -69,7 +70,7 @@ function ProfilePage() { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const route = useRoute>(); useDocumentTitle(translate('common.profile')); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const getPronouns = (): string => { const pronounsKey = currentUserPersonalDetails?.pronouns?.replace(CONST.PRONOUNS.PREFIX, '') ?? ''; return pronounsKey ? translate(`pronouns.${pronounsKey}` as TranslationPaths) : translate('profilePage.selectYourPronouns'); @@ -182,7 +183,7 @@ function ProfilePage() { const privateSectionReasonAttributes: SkeletonSpanReasonAttributes = { context: 'ProfilePage.privateSection', - isLoadingApp: !!isLoadingApp, + isLoadingApp: isAppLoadPending, }; return ( @@ -292,7 +293,7 @@ function ProfilePage() { childrenStyles={styles.pt3} titleStyles={styles.accountSettingsSectionTitle} > - {isLoadingApp ? ( + {isAppLoadPending ? ( ()] = useOnyx(ONYXKEYS.WALLET_TERMS); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isAppLoadPending = useIsAppLoadPending(); const [userAccount] = useOnyx(ONYXKEYS.ACCOUNT); const [lastUsedPaymentMethods] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); @@ -661,8 +662,8 @@ function WalletPage() { ]; }, [bottomMountItem, confirmDeleteCard, icons.MoneySearch, icons.Table, icons.Trashcan, paymentMethod.methodID, selectedCard?.bank, shouldUseNarrowLayout, translate]); - if (isLoadingApp) { - const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'WalletPage', isLoadingApp: !!isLoadingApp}; + if (isAppLoadPending) { + const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'WalletPage', isLoadingApp: isAppLoadPending}; return ( { mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); mockUseOnyx.mockImplementation((key: string) => { - if (key === ONYXKEYS.IS_LOADING_APP) { + // useReportActionsListModel derives app-load state from the request queue via useIsAppLoadPending, + // which reads these queue keys through selectors that resolve to a boolean. Returning that boolean + // directly mirrors what useOnyx yields once the selector runs. The legacy IS_LOADING_APP flag is kept + // in the fixture for any component still reading it directly. + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [false, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { @@ -276,7 +280,7 @@ describe('ReportActionsList (body)', () => { }); mockUseOnyx.mockImplementation((key: string) => { - if (key === ONYXKEYS.IS_LOADING_APP) { + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [true, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { @@ -317,7 +321,7 @@ describe('ReportActionsList (body)', () => { }); mockUseOnyx.mockImplementation((key: string) => { - if (key === ONYXKEYS.IS_LOADING_APP) { + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [false, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { @@ -352,7 +356,7 @@ describe('ReportActionsList (body)', () => { }); mockUseOnyx.mockImplementation((key: string) => { - if (key === ONYXKEYS.IS_LOADING_APP) { + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [true, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { @@ -384,7 +388,7 @@ describe('ReportActionsList (body)', () => { }); mockUseOnyx.mockImplementation((key: string) => { - if (key === ONYXKEYS.IS_LOADING_APP) { + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [false, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { @@ -416,7 +420,7 @@ describe('ReportActionsList (body)', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUseOnyx.mockImplementation((key: string) => { - if (key === ONYXKEYS.IS_LOADING_APP) { + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [true, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { @@ -501,7 +505,7 @@ describe('ReportActionsList (body)', () => { if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { return [CONCIERGE_REPORT_ID, {status: 'loaded'}]; } - if (key === ONYXKEYS.IS_LOADING_APP) { + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [false, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { @@ -662,7 +666,7 @@ describe('ReportActionsList (body)', () => { if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { return [CONCIERGE_REPORT_ID, {status: 'loaded'}]; } - if (key === ONYXKEYS.IS_LOADING_APP) { + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [false, {status: 'loaded'}]; } if (key === ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING) { diff --git a/tests/ui/ReportActionsTest.tsx b/tests/ui/ReportActionsTest.tsx index af6f66bbe2f0..a3410eaa614e 100644 --- a/tests/ui/ReportActionsTest.tsx +++ b/tests/ui/ReportActionsTest.tsx @@ -97,7 +97,7 @@ const mockReport: OnyxTypes.Report = { type ReportLoadingStateOverrides = Partial<{isLoadingInitialReportActions: boolean; hasOnceLoadedReportActions: boolean}>; -/** Builds the keyed useOnyx mock the orchestrator reads: the report, its loading state and IS_LOADING_APP. */ +/** Builds the keyed useOnyx mock the orchestrator reads: the report, its loading state and the app-load request queue. */ const setupUseOnyx = (options: {report?: OnyxTypes.Report | undefined; isLoadingApp?: boolean; loadingState?: ReportLoadingStateOverrides} = {}) => { // `'report' in options` distinguishes "not passed" (default to mockReport) from an explicit // `{report: undefined}` (the report-not-available case) — a destructuring default would swallow the latter. @@ -105,7 +105,10 @@ const setupUseOnyx = (options: {report?: OnyxTypes.Report | undefined; isLoading const isLoadingApp = options.isLoadingApp ?? false; const loadingState = options.loadingState ?? {}; mockUseOnyx.mockImplementation((key: string) => { - if (key === ONYXKEYS.IS_LOADING_APP) { + // ReportActions derives app-load state from the request queue via useIsAppLoadPending, which reads these + // queue keys through selectors that resolve to a boolean. Returning that boolean directly mirrors what + // useOnyx yields once the selector runs. The legacy IS_LOADING_APP flag is kept for any direct reader. + if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { return [isLoadingApp, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { From 22be9a0edccff0f47412b1406846ca1fa4dc82d4 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/4] fix: wait for OpenApp updates to flush Co-authored-by: Cursor --- src/hooks/useInFlightRequests.ts | 73 ++++++++++++++++++++++++++- tests/unit/useInFlightRequestsTest.ts | 23 +++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index b32d77c91b69..4b25b8854cde 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -6,6 +6,9 @@ import type {AnyRequest} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {useSyncExternalStore} from 'react'; +import Onyx from 'react-native-onyx'; + import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -53,6 +56,70 @@ const APP_LOAD_COMMANDS: WriteCommand[] = [WRITE_COMMANDS.OPEN_APP]; const REPORT_LOAD_COMMANDS: WriteCommand[] = [WRITE_COMMANDS.OPEN_REPORT]; const LOADING_BAR_COMMANDS: WriteCommand[] = [WRITE_COMMANDS.OPEN_APP, WRITE_COMMANDS.RECONNECT_APP, WRITE_COMMANDS.OPEN_REPORT, WRITE_COMMANDS.READ_NEWEST_ACTION]; +type OpenAppFlushListener = () => void; + +const openAppFlushListeners = new Set(); +let hasPersistedOpenApp = false; +let hasOngoingOpenApp = false; +let storedIsLoadingApp: boolean | undefined; +let hasObservedOpenApp = false; +let isWaitingForOpenAppFlush = false; + +function updateOpenAppFlushState() { + const hasPendingOpenApp = hasPersistedOpenApp || hasOngoingOpenApp; + if (hasPendingOpenApp) { + hasObservedOpenApp = true; + } else if (storedIsLoadingApp !== true) { + hasObservedOpenApp = false; + } + + const nextIsWaitingForOpenAppFlush = !hasPendingOpenApp && hasObservedOpenApp && storedIsLoadingApp === true; + if (nextIsWaitingForOpenAppFlush === isWaitingForOpenAppFlush) { + return; + } + + isWaitingForOpenAppFlush = nextIsWaitingForOpenAppFlush; + for (const listener of openAppFlushListeners) { + listener(); + } +} + +// Queue presence ends before deferred write updates flush. This process-local observer remembers an +// OpenApp seen in this process until its queued IS_LOADING_APP finallyData clears the flag. It never +// latches from IS_LOADING_APP alone, so a stranded value loaded from disk cannot gate the UI. +Onyx.connectWithoutView({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (requests) => { + hasPersistedOpenApp = !!requests?.some((request) => request.command === WRITE_COMMANDS.OPEN_APP); + updateOpenAppFlushState(); + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.PERSISTED_ONGOING_REQUESTS, + callback: (request) => { + hasOngoingOpenApp = request?.command === WRITE_COMMANDS.OPEN_APP; + updateOpenAppFlushState(); + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.IS_LOADING_APP, + callback: (isLoadingApp) => { + storedIsLoadingApp = isLoadingApp; + updateOpenAppFlushState(); + }, +}); + +function subscribeToOpenAppFlush(listener: OpenAppFlushListener) { + openAppFlushListeners.add(listener); + return () => openAppFlushListeners.delete(listener); +} + +function getIsWaitingForOpenAppFlush() { + return isWaitingForOpenAppFlush; +} + const PENDING_REQUEST_GROUPS = { appLoad: { commands: new Set(APP_LOAD_COMMANDS), @@ -99,9 +166,11 @@ function useIsPendingInternal(group: PendingRequestGroup, scopeKey?: string | nu return !!hasPendingPersistedRequest || !!hasPendingOngoingRequest; } -/** Whether an OpenApp request is currently in the queue (the initial app load, not background reconnects). */ +/** Whether an OpenApp request or its deferred Onyx updates are pending. */ function useIsAppLoadPending(): boolean { - return useIsPendingInternal('appLoad'); + const hasPendingOpenApp = useIsPendingInternal('appLoad'); + const isWaitingForFlush = useSyncExternalStore(subscribeToOpenAppFlush, getIsWaitingForOpenAppFlush, getIsWaitingForOpenAppFlush); + return hasPendingOpenApp || isWaitingForFlush; } /** diff --git a/tests/unit/useInFlightRequestsTest.ts b/tests/unit/useInFlightRequestsTest.ts index 73230d121f2d..0ebeeb5e59a8 100644 --- a/tests/unit/useInFlightRequestsTest.ts +++ b/tests/unit/useInFlightRequestsTest.ts @@ -29,6 +29,8 @@ const setPersistedRequests = (requests: AnyRequest[]) => Onyx.set(ONYXKEYS.PERSI const setOngoingRequest = (request: AnyRequest | null) => Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, request).then(waitForBatchedUpdates); +const setIsLoadingApp = (isLoadingApp: boolean) => Onyx.set(ONYXKEYS.IS_LOADING_APP, isLoadingApp).then(waitForBatchedUpdates); + describe('useInFlightRequests', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -88,6 +90,27 @@ describe('useInFlightRequests', () => { await act(() => setPersistedRequests([])); await waitFor(() => expect(result.current).toBe(false)); }); + + it('waits for the observed OpenApp data flush but ignores a stranded loading flag', async () => { + await setIsLoadingApp(true); + const {result: strandedResult, unmount: unmountStrandedConsumer} = renderHook(() => useIsAppLoadPending()); + await act(() => waitForBatchedUpdates()); + expect(strandedResult.current).toBe(false); + unmountStrandedConsumer(); + + await act(() => setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_APP)])); + const {result: queuedResult, unmount: unmountQueuedConsumer} = renderHook(() => useIsAppLoadPending()); + await waitFor(() => expect(queuedResult.current).toBe(true)); + unmountQueuedConsumer(); + + await act(() => setPersistedRequests([])); + const {result: flushingResult} = renderHook(() => useIsAppLoadPending()); + await act(() => waitForBatchedUpdates()); + expect(flushingResult.current).toBe(true); + + await act(() => setIsLoadingApp(false)); + await waitFor(() => expect(flushingResult.current).toBe(false)); + }); }); describe('useIsReportLoadPending', () => { From 60ef1cc8635dbae9b97facedc1e1a0c3a9f752de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 21 Jul 2026 09:28:50 +0200 Subject: [PATCH 3/4] refactor: use existing OpenApp load markers Co-authored-by: Cursor --- src/hooks/useInFlightRequests.ts | 75 +++------------------------ tests/unit/useInFlightRequestsTest.ts | 7 ++- 2 files changed, 12 insertions(+), 70 deletions(-) diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index 4b25b8854cde..0aac1af0e851 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -6,9 +6,6 @@ import type {AnyRequest} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useSyncExternalStore} from 'react'; -import Onyx from 'react-native-onyx'; - import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -56,70 +53,6 @@ const APP_LOAD_COMMANDS: WriteCommand[] = [WRITE_COMMANDS.OPEN_APP]; const REPORT_LOAD_COMMANDS: WriteCommand[] = [WRITE_COMMANDS.OPEN_REPORT]; const LOADING_BAR_COMMANDS: WriteCommand[] = [WRITE_COMMANDS.OPEN_APP, WRITE_COMMANDS.RECONNECT_APP, WRITE_COMMANDS.OPEN_REPORT, WRITE_COMMANDS.READ_NEWEST_ACTION]; -type OpenAppFlushListener = () => void; - -const openAppFlushListeners = new Set(); -let hasPersistedOpenApp = false; -let hasOngoingOpenApp = false; -let storedIsLoadingApp: boolean | undefined; -let hasObservedOpenApp = false; -let isWaitingForOpenAppFlush = false; - -function updateOpenAppFlushState() { - const hasPendingOpenApp = hasPersistedOpenApp || hasOngoingOpenApp; - if (hasPendingOpenApp) { - hasObservedOpenApp = true; - } else if (storedIsLoadingApp !== true) { - hasObservedOpenApp = false; - } - - const nextIsWaitingForOpenAppFlush = !hasPendingOpenApp && hasObservedOpenApp && storedIsLoadingApp === true; - if (nextIsWaitingForOpenAppFlush === isWaitingForOpenAppFlush) { - return; - } - - isWaitingForOpenAppFlush = nextIsWaitingForOpenAppFlush; - for (const listener of openAppFlushListeners) { - listener(); - } -} - -// Queue presence ends before deferred write updates flush. This process-local observer remembers an -// OpenApp seen in this process until its queued IS_LOADING_APP finallyData clears the flag. It never -// latches from IS_LOADING_APP alone, so a stranded value loaded from disk cannot gate the UI. -Onyx.connectWithoutView({ - key: ONYXKEYS.PERSISTED_REQUESTS, - callback: (requests) => { - hasPersistedOpenApp = !!requests?.some((request) => request.command === WRITE_COMMANDS.OPEN_APP); - updateOpenAppFlushState(); - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.PERSISTED_ONGOING_REQUESTS, - callback: (request) => { - hasOngoingOpenApp = request?.command === WRITE_COMMANDS.OPEN_APP; - updateOpenAppFlushState(); - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.IS_LOADING_APP, - callback: (isLoadingApp) => { - storedIsLoadingApp = isLoadingApp; - updateOpenAppFlushState(); - }, -}); - -function subscribeToOpenAppFlush(listener: OpenAppFlushListener) { - openAppFlushListeners.add(listener); - return () => openAppFlushListeners.delete(listener); -} - -function getIsWaitingForOpenAppFlush() { - return isWaitingForOpenAppFlush; -} - const PENDING_REQUEST_GROUPS = { appLoad: { commands: new Set(APP_LOAD_COMMANDS), @@ -169,8 +102,12 @@ function useIsPendingInternal(group: PendingRequestGroup, scopeKey?: string | nu /** Whether an OpenApp request or its deferred Onyx updates are pending. */ function useIsAppLoadPending(): boolean { const hasPendingOpenApp = useIsPendingInternal('appLoad'); - const isWaitingForFlush = useSyncExternalStore(subscribeToOpenAppFlush, getIsWaitingForOpenAppFlush, getIsWaitingForOpenAppFlush); - return hasPendingOpenApp || isWaitingForFlush; + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); + + // HAS_LOADED_APP is committed only after deferred OpenApp updates flush. Combining both existing + // markers covers that gap without trusting an IS_LOADING_APP value stranded after a completed load. + return hasPendingOpenApp || (isLoadingApp === true && hasLoadedApp !== true); } /** diff --git a/tests/unit/useInFlightRequestsTest.ts b/tests/unit/useInFlightRequestsTest.ts index 0ebeeb5e59a8..09b187e2e7ee 100644 --- a/tests/unit/useInFlightRequestsTest.ts +++ b/tests/unit/useInFlightRequestsTest.ts @@ -31,6 +31,8 @@ const setOngoingRequest = (request: AnyRequest | null) => Onyx.set(ONYXKEYS.PERS const setIsLoadingApp = (isLoadingApp: boolean) => Onyx.set(ONYXKEYS.IS_LOADING_APP, isLoadingApp).then(waitForBatchedUpdates); +const setHasLoadedApp = (hasLoadedApp: boolean) => Onyx.set(ONYXKEYS.HAS_LOADED_APP, hasLoadedApp).then(waitForBatchedUpdates); + describe('useInFlightRequests', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -91,13 +93,15 @@ describe('useInFlightRequests', () => { await waitFor(() => expect(result.current).toBe(false)); }); - it('waits for the observed OpenApp data flush but ignores a stranded loading flag', async () => { + it('waits for the first OpenApp data flush but ignores a loading flag stranded after a completed load', async () => { + await setHasLoadedApp(true); await setIsLoadingApp(true); const {result: strandedResult, unmount: unmountStrandedConsumer} = renderHook(() => useIsAppLoadPending()); await act(() => waitForBatchedUpdates()); expect(strandedResult.current).toBe(false); unmountStrandedConsumer(); + await setHasLoadedApp(false); await act(() => setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_APP)])); const {result: queuedResult, unmount: unmountQueuedConsumer} = renderHook(() => useIsAppLoadPending()); await waitFor(() => expect(queuedResult.current).toBe(true)); @@ -109,6 +113,7 @@ describe('useInFlightRequests', () => { expect(flushingResult.current).toBe(true); await act(() => setIsLoadingApp(false)); + await act(() => setHasLoadedApp(true)); await waitFor(() => expect(flushingResult.current).toBe(false)); }); }); From 68463fe41146caee518dd18c033df64c6fb2895b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Wed, 22 Jul 2026 10:42:17 +0200 Subject: [PATCH 4/4] fix: keep app-load pending through the OpenApp flush window on account switch --- src/hooks/useInFlightRequests.ts | 33 ++++++++++++++++++++++--- tests/unit/useInFlightRequestsTest.ts | 35 ++++++++++++++++++++------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/hooks/useInFlightRequests.ts b/src/hooks/useInFlightRequests.ts index 0aac1af0e851..c712f215bb81 100644 --- a/src/hooks/useInFlightRequests.ts +++ b/src/hooks/useInFlightRequests.ts @@ -6,6 +6,8 @@ import type {AnyRequest} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {useEffect} from 'react'; + import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -99,15 +101,38 @@ function useIsPendingInternal(group: PendingRequestGroup, scopeKey?: string | nu return !!hasPendingPersistedRequest || !!hasPendingOngoingRequest; } +// Process-session memory: an OpenApp was seen in the queue this session and its deferred updates (whose +// finallyData clears IS_LOADING_APP) have not flushed yet. The sequential queue drops the request from +// PERSISTED_(ONGOING_)REQUESTS before it flushes those held updates, so `hasPendingOpenApp` alone goes +// false too early and the migrated screens would render cleared/stale data during that window. This latch +// keeps the gate pending across it. It is NOT a stored flag: a stranded IS_LOADING_APP read from disk on +// a fresh reload never sets it, because that reload runs ReconnectApp, not OpenApp. Keying on an observed +// OpenApp rather than HAS_LOADED_APP is what also covers an account switch, where HAS_LOADED_APP is +// already true but a real OpenApp still fires (see Delegate's atomic reset). +// +// This is deliberately module scoped, not a useRef: the observing consumer can unmount while the flush is +// still in progress (an account switch remounts screens), and a different consumer that mounts during the +// window must still see the latch. Reading a mutable module value during render is safe here because the +// only value the render combines it with is the reactive isLoadingApp, and the latch only changes inside +// the effect below, whose deps are exactly [hasPendingOpenApp, isLoadingApp]: any latch change is therefore +// accompanied by a dep change that re-renders every consumer, so no consumer can strand a stale read. +let hasObservedOpenAppFlushPending = false; + /** Whether an OpenApp request or its deferred Onyx updates are pending. */ function useIsAppLoadPending(): boolean { const hasPendingOpenApp = useIsPendingInternal('appLoad'); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); - // HAS_LOADED_APP is committed only after deferred OpenApp updates flush. Combining both existing - // markers covers that gap without trusting an IS_LOADING_APP value stranded after a completed load. - return hasPendingOpenApp || (isLoadingApp === true && hasLoadedApp !== true); + useEffect(() => { + if (hasPendingOpenApp) { + hasObservedOpenAppFlushPending = true; + } else if (isLoadingApp !== true) { + // The flag cleared, so the deferred OpenApp updates flushed: stop covering the window. + hasObservedOpenAppFlushPending = false; + } + }, [hasPendingOpenApp, isLoadingApp]); + + return hasPendingOpenApp || (hasObservedOpenAppFlushPending && isLoadingApp === true); } /** diff --git a/tests/unit/useInFlightRequestsTest.ts b/tests/unit/useInFlightRequestsTest.ts index 09b187e2e7ee..498d2bb71312 100644 --- a/tests/unit/useInFlightRequestsTest.ts +++ b/tests/unit/useInFlightRequestsTest.ts @@ -38,8 +38,13 @@ describe('useInFlightRequests', () => { Onyx.init({keys: ONYXKEYS}); }); - beforeEach(() => { - return Onyx.clear().then(waitForBatchedUpdates); + 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()); + await act(() => waitForBatchedUpdates()); + unmount(); }); describe('useIsAppLoadPending', () => { @@ -93,29 +98,41 @@ describe('useInFlightRequests', () => { await waitFor(() => expect(result.current).toBe(false)); }); - it('waits for the first OpenApp data flush but ignores a loading flag stranded after a completed load', async () => { + it('stays pending across the OpenApp flush window even when the app was already loaded (account switch)', async () => { + // An account switch preserves HAS_LOADED_APP=true and seeds IS_LOADING_APP=true before firing OpenApp. + // The request's data and its IS_LOADING_APP clear are deferred until the queue drains, but the request + // itself leaves the queue earlier, so gating on the queue alone (or on HAS_LOADED_APP, already true here) + // would drop the skeleton mid-flush and render cleared account data. await setHasLoadedApp(true); await setIsLoadingApp(true); - const {result: strandedResult, unmount: unmountStrandedConsumer} = renderHook(() => useIsAppLoadPending()); - await act(() => waitForBatchedUpdates()); - expect(strandedResult.current).toBe(false); - unmountStrandedConsumer(); - await setHasLoadedApp(false); + // OpenApp observed in the queue. await act(() => setPersistedRequests([buildRequest(WRITE_COMMANDS.OPEN_APP)])); const {result: queuedResult, unmount: unmountQueuedConsumer} = renderHook(() => useIsAppLoadPending()); await waitFor(() => expect(queuedResult.current).toBe(true)); unmountQueuedConsumer(); + // Request removed but its deferred updates have not flushed (IS_LOADING_APP still true). A consumer that + // mounts now, during the flush window, must still report pending even though HAS_LOADED_APP is true. await act(() => setPersistedRequests([])); const {result: flushingResult} = renderHook(() => useIsAppLoadPending()); await act(() => waitForBatchedUpdates()); expect(flushingResult.current).toBe(true); + // Deferred updates flush and clear the flag: resolved. await act(() => setIsLoadingApp(false)); - await act(() => setHasLoadedApp(true)); await waitFor(() => expect(flushingResult.current).toBe(false)); }); + + it('ignores an IS_LOADING_APP flag stranded on disk when no OpenApp ran this session', async () => { + // A fresh reload after an interrupted load: IS_LOADING_APP is stranded true from a previous session, + // but this session runs ReconnectApp, not OpenApp, so no OpenApp is ever observed in the queue. + await setHasLoadedApp(true); + await setIsLoadingApp(true); + const {result} = renderHook(() => useIsAppLoadPending()); + await act(() => waitForBatchedUpdates()); + expect(result.current).toBe(false); + }); }); describe('useIsReportLoadPending', () => {