From 496b702b1286a40bdc9a982b5931cb3fdb193d45 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Mon, 30 Mar 2026 17:13:47 +0200 Subject: [PATCH 01/11] convert ONYXKEYS.IS_CHECKING_PUBLIC_ROOM to a regular key with no initWithStoredValues, add initial state --- src/Expensify.tsx | 2 +- src/setup/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 82900c0ef0a6..825f6c78fbde 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -58,7 +58,7 @@ function Expensify() { const {preferredLocale} = useLocalize(); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); - const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, {initWithStoredValues: false}); + const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM); const [updateRequired] = useOnyx(ONYXKEYS.UPDATE_REQUIRED, {initWithStoredValues: false}); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); diff --git a/src/setup/index.ts b/src/setup/index.ts index af67594d1cdc..3026dfb4bd1b 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -57,6 +57,7 @@ export default function () { // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, + [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: true, }, skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS, snapshotMergeKeys: ['pendingAction', 'pendingFields'], From cdfedb158f1ab03ea27c3595f4ba15fdff4cdf3c Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Fri, 3 Apr 2026 16:40:42 +0200 Subject: [PATCH 02/11] implement a hook that emulates initWithStoredValues: false --- src/Expensify.tsx | 3 ++- src/hooks/useOnyxWithMountReset.ts | 34 ++++++++++++++++++++++++++++++ src/setup/index.ts | 1 - 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useOnyxWithMountReset.ts diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 27c3abf72630..0d00af7b9cdc 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -17,6 +17,7 @@ import useDebugShortcut from './hooks/useDebugShortcut'; import useIsAuthenticated from './hooks/useIsAuthenticated'; import useLocalize from './hooks/useLocalize'; import useOnyx from './hooks/useOnyx'; +import useOnyxWithMountReset from './hooks/useOnyxWithMountReset'; import {updateLastRoute} from './libs/actions/App'; import * as ActiveClientManager from './libs/ActiveClientManager'; import {isSafari} from './libs/Browser'; @@ -58,7 +59,7 @@ function Expensify() { const {preferredLocale} = useLocalize(); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); - const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM); + const [isCheckingPublicRoom = true] = useOnyxWithMountReset(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, true); const [updateRequired] = useOnyx(ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); diff --git a/src/hooks/useOnyxWithMountReset.ts b/src/hooks/useOnyxWithMountReset.ts new file mode 100644 index 000000000000..32595a8bd850 --- /dev/null +++ b/src/hooks/useOnyxWithMountReset.ts @@ -0,0 +1,34 @@ +import {useEffect, useRef} from 'react'; +import Onyx from 'react-native-onyx'; +import type {OnyxKey, OnyxSetInput, OnyxValue, UseOnyxResult} from 'react-native-onyx'; +import useOnyx from './useOnyx'; + +/** + * Wraps `useOnyx` to guarantee a fresh start on every component mount. + * + * On the first render after mount, the hook returns `resetValue` instead of + * whatever is in the Onyx cache. A mount effect then calls `Onyx.set` to + * align the cache with `resetValue`, so subsequent renders read the correct + * value from `useOnyx`. + * + * This replaces `initWithStoredValues: false` for keys that must reset to a + * known value on every mount (e.g. IS_CHECKING_PUBLIC_ROOM must start as + * `true` so NavigationRoot doesn't render before the initial URL is resolved). + */ +function useOnyxWithMountReset(key: TKey, resetValue: NonNullable>): UseOnyxResult> { + const hasResetRef = useRef(false); + const result = useOnyx(key); + + useEffect(() => { + hasResetRef.current = true; + Onyx.set(key, resetValue as OnyxSetInput); + }, [key, resetValue]); + + if (!hasResetRef.current) { + return [resetValue, {status: 'loaded'}]; + } + + return result; +} + +export default useOnyxWithMountReset; diff --git a/src/setup/index.ts b/src/setup/index.ts index ef26dd327906..0daed09b2bcb 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -57,7 +57,6 @@ export default function () { // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, - [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: true, }, skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS, snapshotMergeKeys: ['pendingAction', 'pendingFields'], From a83bd563b41efdcb82b8ea2883311ed523475c6c Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Fri, 10 Apr 2026 15:02:19 +0200 Subject: [PATCH 03/11] replace custom hook solution with initialUrl gate --- src/CONST/index.ts | 1 + src/DeepLinkHandler.tsx | 78 ++++++++++++++++++++++----- src/Expensify.tsx | 12 +++-- src/ONYXKEYS.ts | 4 +- src/hooks/useOnyxWithMountReset.ts | 34 ------------ src/libs/actions/App.ts | 2 +- src/libs/actions/QueuedOnyxUpdates.ts | 2 +- src/libs/actions/Report/index.ts | 6 +-- src/setup/index.ts | 1 + tests/unit/APITest.ts | 6 +-- 10 files changed, 83 insertions(+), 63 deletions(-) delete mode 100644 src/hooks/useOnyxWithMountReset.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ea9c5658533b..a9da9d55ef9b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1936,6 +1936,7 @@ const CONST = { SHOW_HOVER_PREVIEW_DELAY: 270, SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 250, ACTIVITY_INDICATOR_TIMEOUT: 10000, + GET_INITIAL_URL_TIMEOUT: 10000, MIN_SMOOTH_SCROLL_EVENT_THROTTLE: 16, }, DEFERRED_LAYOUT_WRITE_KEYS: { diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 81b35023ba35..1745e2c8ede4 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -26,6 +26,7 @@ type DeepLinkHandlerProps = { */ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const linkingChangeListener = useRef(null); + const initialUrlProcessed = useRef(false); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [, sessionMetadata] = useOnyx(ONYXKEYS.SESSION); @@ -39,24 +40,58 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { if (isLoadingOnyxValue(sessionMetadata, isSelfTourViewedMetadata)) { return; } - // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report - Linking.getInitialURL().then((url) => { - onInitialUrl(url as Route); - if (url) { - if (conciergeReportID === undefined) { - Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url}); + // Guard against stale closures: when deps change and the effect re-runs, the previous + // getInitialURL() promise may still be in-flight. Without this guard, its .then() would + // fire with stale conciergeReportID/introSelected values, causing a duplicate + // openReportFromDeepLink() call. + let cancelled = false; + let timeoutId: ReturnType; + + // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report. + // We race against a timeout to prevent permanently blocking NavigationRoot if getInitialURL() never resolves + // (e.g. in HybridApp when OldDot fails to send the URL via native bridge). + Promise.race([ + Linking.getInitialURL(), + new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(null), CONST.TIMING.GET_INITIAL_URL_TIMEOUT); + }), + ]) + .then((url) => { + if (cancelled) { + return; } - if (introSelected === undefined) { - Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url}); + + initialUrlProcessed.current = true; + onInitialUrl(url as Route); + + if (url) { + if (conciergeReportID === undefined) { + Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url}); + } + if (introSelected === undefined) { + Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url}); + } + // Use hasAuthToken() for the latest auth state at call time, since the isAuthenticated + // closure value may be stale on cold start (useOnyx reports 'loaded' before storage completes). + const isCurrentlyAuthenticated = hasAuthToken(); + openReportFromDeepLink(url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); + } else { + Report.doneCheckingPublicRoom(); } - openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); - } else { - Report.doneCheckingPublicRoom(); - } - endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK); - }); + endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK); + }) + .catch(() => { + if (cancelled) { + return; + } + + initialUrlProcessed.current = true; + onInitialUrl(null); + Report.doneCheckingPublicRoom(); + endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK); + }); // Open chat report from a deep link (only mobile native) linkingChangeListener.current = Linking.addEventListener('url', (state) => { @@ -71,11 +106,26 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { }); return () => { + cancelled = true; + clearTimeout(timeoutId); linkingChangeListener.current?.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excluding allReports, isAuthenticated, and onInitialUrl to avoid re-triggering deep link handling on every report update }, [sessionMetadata?.status, conciergeReportID, introSelected, isSelfTourViewedMetadata, betas]); + // Safety net: if getInitialURL() resolves before the session loads, hasAuthToken() may return false + // for an authenticated user, causing openReportFromDeepLink to take the wrong path. Once isAuthenticated + // settles to true, unblock the UI. The initialUrlProcessed guard ensures this doesn't fire before URL + // resolution. In the common case (isAuthenticated settles first), this is a no-op because + // openReportFromDeepLink's own doneCheckingPublicRoom() call handles it. + useEffect(() => { + if (!isAuthenticated || !initialUrlProcessed.current) { + return; + } + + Report.doneCheckingPublicRoom(); + }, [isAuthenticated]); + return null; } diff --git a/src/Expensify.tsx b/src/Expensify.tsx index c8051af234a5..e9fa50f3091c 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -17,7 +17,6 @@ import useDebugShortcut from './hooks/useDebugShortcut'; import useIsAuthenticated from './hooks/useIsAuthenticated'; import useLocalize from './hooks/useLocalize'; import useOnyx from './hooks/useOnyx'; -import useOnyxWithMountReset from './hooks/useOnyxWithMountReset'; import {updateLastRoute} from './libs/actions/App'; import * as ActiveClientManager from './libs/ActiveClientManager'; import {isSafari} from './libs/Browser'; @@ -59,7 +58,7 @@ function Expensify() { const {preferredLocale} = useLocalize(); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); - const [isCheckingPublicRoom = true] = useOnyxWithMountReset(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, true); + const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM); const [updateRequired] = useOnyx(ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); @@ -74,7 +73,7 @@ function Expensify() { const bootsplashSpan = useRef(null); - const [initialUrl, setInitialUrl] = useState(null); + const [initialUrl, setInitialUrl] = useState(undefined); const {setIsAuthenticatedAtStartup} = useInitialURLActions(); useEffect(() => { @@ -285,12 +284,15 @@ function Expensify() { - {hasAttemptedToOpenPublicRoom && ( + {/* Wait for the initial URL to resolve before mounting NavigationRoot, because its initialState + is computed once on mount. In HybridApp, getInitialURL() may never resolve (OldDot native + bridge), so we skip this guard to avoid blocking the app. */} + {hasAttemptedToOpenPublicRoom && (CONFIG.IS_HYBRID_APP || initialUrl !== undefined) && ( )} {(isSplashVisible || isSplashReadyToBeHidden) && ( diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0fa0a003371b..9aedb0878431 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -421,7 +421,7 @@ const ONYXKEYS = { IS_BETA: 'isBeta', /** Whether we're checking if the room is public or not */ - IS_CHECKING_PUBLIC_ROOM: 'isCheckingPublicRoom', + RAM_ONLY_IS_CHECKING_PUBLIC_ROOM: 'isCheckingPublicRoom', /** A map of the user's security group IDs they belong to in specific domains */ MY_DOMAIN_SECURITY_GROUPS: 'myDomainSecurityGroups', @@ -1422,7 +1422,7 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean; [ONYXKEYS.IS_BETA]: boolean; - [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: boolean; + [ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM]: boolean; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; diff --git a/src/hooks/useOnyxWithMountReset.ts b/src/hooks/useOnyxWithMountReset.ts deleted file mode 100644 index 32595a8bd850..000000000000 --- a/src/hooks/useOnyxWithMountReset.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {useEffect, useRef} from 'react'; -import Onyx from 'react-native-onyx'; -import type {OnyxKey, OnyxSetInput, OnyxValue, UseOnyxResult} from 'react-native-onyx'; -import useOnyx from './useOnyx'; - -/** - * Wraps `useOnyx` to guarantee a fresh start on every component mount. - * - * On the first render after mount, the hook returns `resetValue` instead of - * whatever is in the Onyx cache. A mount effect then calls `Onyx.set` to - * align the cache with `resetValue`, so subsequent renders read the correct - * value from `useOnyx`. - * - * This replaces `initWithStoredValues: false` for keys that must reset to a - * known value on every mount (e.g. IS_CHECKING_PUBLIC_ROOM must start as - * `true` so NavigationRoot doesn't render before the initial URL is resolved). - */ -function useOnyxWithMountReset(key: TKey, resetValue: NonNullable>): UseOnyxResult> { - const hasResetRef = useRef(false); - const result = useOnyx(key); - - useEffect(() => { - hasResetRef.current = true; - Onyx.set(key, resetValue as OnyxSetInput); - }, [key, resetValue]); - - if (!hasResetRef.current) { - return [resetValue, {status: 'loaded'}]; - } - - return result; -} - -export default useOnyxWithMountReset; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 49a03f553838..cafcc49c607a 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -122,7 +122,7 @@ Onyx.connectWithoutView({ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.ACCOUNT, - ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, + ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.IS_LOADING_APP, ONYXKEYS.IS_SIDEBAR_LOADED, ONYXKEYS.MODAL, diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index be2a1480ccec..cbb735e4dc30 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -45,7 +45,7 @@ function flushQueue(): Promise { ONYXKEYS.CREDENTIALS, ONYXKEYS.IS_SIDEBAR_LOADED, ONYXKEYS.ACCOUNT, - ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, + ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.MODAL, ONYXKEYS.NETWORK, ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index a9e5d5f5b452..dd99595ea67c 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -1370,7 +1370,7 @@ function openReport(params: OpenReportActionParams) { }); } - const finallyData: Array> = []; + const finallyData: Array> = []; const parameters: OpenReportParams = { reportID, @@ -1639,7 +1639,7 @@ function openReport(params: OpenReportActionParams) { if (isFromDeepLink) { finallyData.push({ onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, + key: ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, value: false, }); @@ -4281,7 +4281,7 @@ function toggleEmojiReaction( } function doneCheckingPublicRoom() { - Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false); + Onyx.set(ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, false); } function navigateToMostRecentReport( diff --git a/src/setup/index.ts b/src/setup/index.ts index 0daed09b2bcb..2d5a990e7bd5 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -62,6 +62,7 @@ export default function () { snapshotMergeKeys: ['pendingAction', 'pendingFields'], ramOnlyKeys: [ ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS, + ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, diff --git a/tests/unit/APITest.ts b/tests/unit/APITest.ts index 8e81e4bfd42a..1b011574482f 100644 --- a/tests/unit/APITest.ts +++ b/tests/unit/APITest.ts @@ -874,8 +874,8 @@ describe('API.write() persistence guarantees', () => { // persisted-requests queue. This avoids spy-ordering issues that caused // false passes on CI. const updateMock = jest.spyOn(Onyx, 'update').mockImplementation((data) => { - // We use ONYXKEYS.IS_CHECKING_PUBLIC_ROOM as a sample key to identify the marker - const hasMarker = data.some((entry) => entry.key === ONYXKEYS.IS_CHECKING_PUBLIC_ROOM); + // We use ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM as a sample key to identify the marker + const hasMarker = data.some((entry) => entry.key === ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM); if (hasMarker) { optimisticDataApplied = true; // Note: getAll() checks the in-memory queue, not durable (disk) state. @@ -892,7 +892,7 @@ describe('API.write() persistence guarantees', () => { optimisticData: [ { onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, + key: ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, value: true, }, ], From 382b6a290425e1ea0af2ea9bf3e680f2c76592d3 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Fri, 10 Apr 2026 15:51:05 +0200 Subject: [PATCH 04/11] remove initWithStoredValues from IS_LOADING_APP --- .../routes/WorkspaceAvatarModalContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/WorkspaceAvatarModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/WorkspaceAvatarModalContent.tsx index 70fa08ede752..f1e2083d3cab 100644 --- a/src/pages/media/AttachmentModalScreen/routes/WorkspaceAvatarModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/WorkspaceAvatarModalContent.tsx @@ -16,7 +16,7 @@ function WorkspaceAvatarModalContent({navigation, route}: AttachmentModalScreenP const defaultAvatars = useDefaultAvatars(); const policy = usePolicy(policyID); - const [isLoadingApp = false] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initWithStoredValues: false}); + const [isLoadingApp = false] = useOnyx(ONYXKEYS.IS_LOADING_APP); const avatarURL = policy?.avatarURL ?? getDefaultWorkspaceAvatar(policy?.name ?? fallbackLetter); const source = getFullSizeAvatar({avatarSource: avatarURL, defaultAvatars}); From 35012d871ec5ef9aa6f8001d2f5ed5227bfb9d35 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Fri, 10 Apr 2026 15:52:29 +0200 Subject: [PATCH 05/11] remove initWithStoredValues from IS_LOADING_REPORT_DATA, prevent regressions --- src/libs/actions/Welcome/index.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index a10f96e62a8e..252f6a83a04e 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -16,6 +16,10 @@ import type Onboarding from '@src/types/onyx/Onboarding'; import type {OnboardingCompanySize} from './OnboardingFlow'; let isLoadingReportData = true; +// Tracks whether we've seen loading start (true) in the current session. +// Without this, a stale persisted `false` from a previous session would +// resolve the onServerDataReady() promise before OpenApp/ReconnectApp completes. +let hasStartedLoading = false; let resolveIsReadyPromise: (value?: Promise) => void | undefined; let isServerDataReadyPromise = new Promise((resolve) => { @@ -116,10 +120,16 @@ function completeHybridAppOnboarding() { // and doesn't need to trigger component re-renders. Onyx.connectWithoutView({ key: ONYXKEYS.IS_LOADING_REPORT_DATA, - initWithStoredValues: false, callback: (value) => { isLoadingReportData = value ?? false; - checkServerDataReady(); + if (isLoadingReportData) { + hasStartedLoading = true; + } + // Only resolve once loading has started — this ensures a stale + // persisted `false` from a previous session is ignored. + if (hasStartedLoading) { + checkServerDataReady(); + } }, }); @@ -128,6 +138,7 @@ function resetAllChecks() { resolveIsReadyPromise = resolve; }); isLoadingReportData = true; + hasStartedLoading = false; } function setSelfTourViewed(shouldUpdateOnyxDataOnlyLocally = false) { From 934b41bcc73b031122378a040bf1416afcfdd2bc Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Fri, 10 Apr 2026 15:52:56 +0200 Subject: [PATCH 06/11] migrate PLAID_LINK_TOKEN to RAM-only --- src/ONYXKEYS.ts | 4 +-- src/components/AddPlaidBankAccount.tsx | 2 +- src/libs/ExportOnyxState/common.ts | 2 +- src/libs/actions/BankAccounts.ts | 2 +- src/libs/actions/Plaid.ts | 28 ++++++++++++++++--- .../resetUSDBankAccount.ts | 4 +-- .../USD/BankInfo/BankInfo.tsx | 2 +- .../steps/PlaidConnectionStep.tsx | 2 +- .../addNew/PlaidConnectionStep.tsx | 2 +- src/setup/index.ts | 1 + tests/unit/ExportOnyxStateTest.ts | 4 +-- 11 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9aedb0878431..86b33d5af433 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -315,7 +315,7 @@ const ONYXKEYS = { IS_PLAID_DISABLED: 'isPlaidDisabled', /** Token needed to initialize Plaid link */ - PLAID_LINK_TOKEN: 'plaidLinkToken', + RAM_ONLY_PLAID_LINK_TOKEN: 'plaidLinkToken', /** Capture Plaid event */ PLAID_CURRENT_EVENT: 'plaidCurrentEvent', @@ -1372,7 +1372,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_SEEN_NEW_USER_MODAL]: boolean; [ONYXKEYS.PLAID_DATA]: OnyxTypes.PlaidData; [ONYXKEYS.IS_PLAID_DISABLED]: boolean; - [ONYXKEYS.PLAID_LINK_TOKEN]: string; + [ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN]: string; [ONYXKEYS.ONFIDO_TOKEN]: string; [ONYXKEYS.ONFIDO_APPLICANT_ID]: string; [ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index c68624505d5a..626e804f4bd0 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -83,7 +83,7 @@ function AddPlaidBankAccount({ const subscribedKeyboardShortcuts = useRef void>>([]); const previousNetworkState = useRef(undefined); const [selectedPlaidAccountMask, setSelectedPlaidAccountMask] = useState(defaultSelectedPlaidAccountMask); - const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN, {initWithStoredValues: false}); + const [plaidLinkToken] = useOnyx(ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN); const [isPlaidDisabled] = useOnyx(ONYXKEYS.IS_PLAID_DISABLED); const {translate} = useLocalize(); const {isOffline} = useNetwork(); diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 872fb7a95099..80198ea446b2 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -80,7 +80,7 @@ const onyxKeysToRemove = new Set>([ ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID, ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING, ONYXKEYS.NVP_PRIVATE_BILLING_STATUS, - ONYXKEYS.PLAID_LINK_TOKEN, + ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.ONFIDO_TOKEN, ONYXKEYS.ONFIDO_APPLICANT_ID, ]); diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index a4b1a8c45e7b..56cfdaa83cad 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -87,7 +87,7 @@ type OpenPersonalBankAccountSetupViewProps = { }; function clearPlaid(): Promise { - Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, ''); + Onyx.set(ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ''); Onyx.set(ONYXKEYS.PLAID_CURRENT_EVENT, null); return Onyx.set(ONYXKEYS.PLAID_DATA, CONST.PLAID.DEFAULT_DATA); } diff --git a/src/libs/actions/Plaid.ts b/src/libs/actions/Plaid.ts index d7f44c40df62..eb0d6f59ff54 100644 --- a/src/libs/actions/Plaid.ts +++ b/src/libs/actions/Plaid.ts @@ -31,7 +31,7 @@ function openPlaidBankLogin(allowDebit: boolean, bankAccountID: number) { }, { onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.PLAID_LINK_TOKEN, + key: ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, value: '', }, { @@ -43,7 +43,17 @@ function openPlaidBankLogin(allowDebit: boolean, bankAccountID: number) { }, ]; - API.read(READ_COMMANDS.OPEN_PLAID_BANK_LOGIN, params, {optimisticData}); + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PLAID_DATA, + value: { + isLoading: false, + }, + }, + ]; + + API.read(READ_COMMANDS.OPEN_PLAID_BANK_LOGIN, params, {optimisticData, failureData}); } /** @@ -69,12 +79,22 @@ function openPlaidCompanyCardLogin(country: string, domain?: string, feed?: Card }, { onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.PLAID_LINK_TOKEN, + key: ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, value: '', }, ]; - API.read(READ_COMMANDS.OPEN_PLAID_CARDS_BANK_LOGIN, params, {optimisticData}); + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PLAID_DATA, + value: { + isLoading: false, + }, + }, + ]; + + API.read(READ_COMMANDS.OPEN_PLAID_CARDS_BANK_LOGIN, params, {optimisticData, failureData}); } function openPlaidBankAccountSelector(publicToken: string, bankName: string, allowDebit: boolean, bankAccountID: number) { diff --git a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts index ece32c9c1f4b..ce33a5b144cc 100644 --- a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts @@ -35,7 +35,7 @@ function resetUSDBankAccount( | typeof ONYXKEYS.ONFIDO_TOKEN | typeof ONYXKEYS.ONFIDO_APPLICANT_ID | typeof ONYXKEYS.PLAID_DATA - | typeof ONYXKEYS.PLAID_LINK_TOKEN + | typeof ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN | typeof ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT | typeof ONYXKEYS.NVP_LAST_PAYMENT_METHOD > = { @@ -76,7 +76,7 @@ function resetUSDBankAccount( }, { onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.PLAID_LINK_TOKEN, + key: ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, value: '', }, { diff --git a/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx index 8c372c40c7b8..58562096c77a 100644 --- a/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx @@ -32,7 +32,7 @@ const receivedRedirectURI = getPlaidOAuthReceivedRedirectURI(); function BankInfo({onBackButtonPress, onSubmit, policyID}: BankInfoProps) { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN); + const [plaidLinkToken] = useOnyx(ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN); const {translate} = useLocalize(); const markSubmitting = useReimbursementAccountSubmitCallback(onSubmit); diff --git a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx index b86e05b9abb7..7ccb209cf18a 100644 --- a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx +++ b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx @@ -75,7 +75,7 @@ function PlaidConnectionStep({feed, onExit}: {feed?: CompanyCardFeedWithDomainID const [addNewPersonalCard] = useOnyx(ONYXKEYS.ADD_NEW_PERSONAL_CARD); const isUSCountry = addNewPersonalCard?.data?.selectedCountry === CONST.COUNTRY.US; const [isPlaidDisabled] = useOnyx(ONYXKEYS.IS_PLAID_DISABLED); - const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN); + const [plaidLinkToken] = useOnyx(ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN); const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); const plaidErrors = plaidData?.errors; const subscribedKeyboardShortcuts = useRef void>>([]); diff --git a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx index 3af4bce12410..9eba2712e90c 100644 --- a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx +++ b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx @@ -40,7 +40,7 @@ function PlaidConnectionStep({feed, policyID, onExit, title}: PlaidConnectionSte const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); const isUSCountry = addNewCard?.data?.selectedCountry === CONST.COUNTRY.US; const [isPlaidDisabled] = useOnyx(ONYXKEYS.IS_PLAID_DISABLED); - const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN); + const [plaidLinkToken] = useOnyx(ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN); const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); const plaidErrors = plaidData?.errors; const subscribedKeyboardShortcuts = useRef void>>([]); diff --git a/src/setup/index.ts b/src/setup/index.ts index 2d5a990e7bd5..b72addec7bd8 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -67,6 +67,7 @@ export default function () { ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, + ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ], }); diff --git a/tests/unit/ExportOnyxStateTest.ts b/tests/unit/ExportOnyxStateTest.ts index 9d516916ef86..b1037ab04ad1 100644 --- a/tests/unit/ExportOnyxStateTest.ts +++ b/tests/unit/ExportOnyxStateTest.ts @@ -123,7 +123,7 @@ describe('maskOnyxState', () => { session: mockSession, [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: 'sensitive-id', [ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID]: 'stripe-id', - [ONYXKEYS.PLAID_LINK_TOKEN]: 'plaid-token', + [ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN]: 'plaid-token', [ONYXKEYS.ONFIDO_TOKEN]: 'onfido-token', }; const result = maskOnyxState(input); @@ -131,7 +131,7 @@ describe('maskOnyxState', () => { // Sensitive keys should be removed expect(result[ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]).toBeUndefined(); expect(result[ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID]).toBeUndefined(); - expect(result[ONYXKEYS.PLAID_LINK_TOKEN]).toBeUndefined(); + expect(result[ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN]).toBeUndefined(); expect(result[ONYXKEYS.ONFIDO_TOKEN]).toBeUndefined(); // Session should still be present From 262a3047f1b986bcb2ac2115c7c4d3d83b869b9c Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Fri, 17 Apr 2026 16:26:37 +0200 Subject: [PATCH 07/11] fix: remove USDBankAccountStep from effect deps to avoid race with prepareNextStep --- src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index fe6e3f36e7cf..695634d558e6 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -271,7 +271,11 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy, navigation}: setShouldShowConnectedVerifiedBankAccount(isNonUSDSetup ? achData?.state === CONST.BANK_ACCOUNT.STATE.OPEN : achData?.currentStep === CONST.BANK_ACCOUNT.STEP.ENABLE); setShouldShowContinueSetupButton(shouldShowContinueSetupButtonValue); - }, [policyIDParam, achData?.currentStep, shouldShowContinueSetupButtonValue, isNonUSDSetup, isPreviousPolicy, achData?.state, policyCurrency, USDBankAccountStep]); + // USDBankAccountStep is intentionally omitted from deps. This effect must only react to server-side + // achData changes — not to local USDBankAccountStep updates — otherwise it races with prepareNextStep + // and briefly pulls USDBankAccountStep back to the server value before the Onyx merge lands. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [policyIDParam, achData?.currentStep, shouldShowContinueSetupButtonValue, isNonUSDSetup, isPreviousPolicy, achData?.state, policyCurrency]); useEffect(() => { if (!prevPolicyCurrency || policyCurrency === prevPolicyCurrency) { From d998f91d5affb39c8ee9988cec5105b35751ea75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Tue, 21 Apr 2026 15:37:57 +0100 Subject: [PATCH 08/11] Migrate ISSUE_NEW_EXPENSIFY_CARD to RAM-only --- src/ONYXKEYS.ts | 4 ++-- src/libs/actions/Card.ts | 18 +++++++++--------- .../expensifyCard/issueNew/AssigneeStep.tsx | 2 +- .../expensifyCard/issueNew/CardNameStep.tsx | 2 +- .../expensifyCard/issueNew/CardTypeStep.tsx | 2 +- .../issueNew/ConfirmationStep.tsx | 2 +- .../issueNew/InviteNewMemberStep.tsx | 2 +- .../IssueNewCardConfirmMagicCodePage.tsx | 2 +- .../issueNew/IssueNewCardPage.tsx | 2 +- .../expensifyCard/issueNew/LimitTypeStep.tsx | 2 +- .../issueNew/SetExpiryOptionsStep.tsx | 2 +- src/setup/index.ts | 1 + 12 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 740378a30897..0a0db1bed41f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -808,7 +808,7 @@ const ONYXKEYS = { NVP_EXPENSIFY_REPORT_PDF_FILENAME: 'nvp_expensify_report_PDFFilename_', /** Stores the information about the state of issuing a new card */ - ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', + RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', /** Stored the user information with whom bank account is being shared */ BANK_ACCOUNT_SHARE_DETAILS: 'expensify_bankAccountShare_', @@ -1294,7 +1294,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.LAST_SELECTED_FEED]: OnyxTypes.CompanyCardFeedWithDomainID; [ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED]: OnyxTypes.FundID; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist; - [ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; + [ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; [ONYXKEYS.COLLECTION.SAML_METADATA]: OnyxTypes.SamlMetadata; [ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS]: OnyxTypes.DomainPendingActions; [ONYXKEYS.COLLECTION.DOMAIN_ERRORS]: OnyxTypes.DomainErrors; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 72094dce42b4..6d5c5dd9e5f2 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -748,7 +748,7 @@ function getCardDefaultName(userName?: string) { } function setIssueNewCardStepAndData({data, isEditing, step, policyID, isChangeAssigneeDisabled}: IssueNewCardFlowData) { - Onyx.merge(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, { + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, { data, isEditing, currentStep: step, @@ -764,7 +764,7 @@ function setDraftInviteAccountID(assigneeEmail: string | undefined, assigneeAcco } function clearIssueNewCardFlow(policyID: string | undefined) { - Onyx.set(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, { + Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, { currentStep: null, data: {}, isSuccessful: false, @@ -777,7 +777,7 @@ function clearIssueNewCardFormData() { } function clearIssueNewCardError(policyID: string | undefined) { - Onyx.merge(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {errors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {errors: null}); } function buildCardListUpdates(workspaceAccountID: number, cardID: number, cardUpdateData: CardListUpdateData, shouldUpdateCardList: boolean): CardOnyxUpdate[] { @@ -1367,10 +1367,10 @@ function issueExpensifyCard( const {assigneeEmail, limit, limitType, cardTitle, cardType, validFrom, validThru} = data; - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, value: { isLoading: true, errors: null, @@ -1379,10 +1379,10 @@ function issueExpensifyCard( }, ]; - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, value: { isLoading: false, isSuccessful: true, @@ -1390,10 +1390,10 @@ function issueExpensifyCard( }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, value: { isLoading: false, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 815e4e82228b..8b7e055d005c 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -47,7 +47,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const {isOffline} = useNetwork(); const policyID = route.params.policyID; - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); diff --git a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx index 0848e753f410..07ce73efac1b 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx @@ -34,7 +34,7 @@ function CardNameStep({policyID, stepNames, startStepIndex}: CardNameStepProps) const {translate} = useLocalize(); const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const isEditing = issueNewCard?.isEditing; const data = issueNewCard?.data; diff --git a/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx index c88c6844a3a9..72675705449e 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx @@ -33,7 +33,7 @@ function CardTypeStep({policy, stepNames, startStepIndex}: CardTypeStepProps) { const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['HandCard', 'VirtualCard']); const policyID = policy?.id; - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const isEditing = issueNewCard?.isEditing; diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index 1a9de688ff4a..5a6c1516abd1 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -45,7 +45,7 @@ function ConfirmationStep({policyID, stepNames, startStepIndex, backTo}: Confirm const styles = useThemeStyles(); const {isOffline} = useNetwork(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const defaultFundID = useDefaultFundID(policyID); const {isBetaEnabled} = usePermissions(); diff --git a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx index 16aaa18f6caa..813fa306e413 100644 --- a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx @@ -22,7 +22,7 @@ type InviteeNewMemberStepProps = Omit { diff --git a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx index b2c2e01fe5d9..242fc4626e02 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx @@ -26,7 +26,7 @@ function IssueNewCardConfirmMagicCodePage({route}: IssueNewCardConfirmMagicCodeP const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [session] = useOnyx(ONYXKEYS.SESSION); const primaryLogin = account?.primaryLogin ?? session?.email ?? ''; - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const validateError = getLatestErrorMessageField(issueNewCard); const data = issueNewCard?.data; const isSuccessful = issueNewCard?.isSuccessful; diff --git a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx index ac6bd562bfbe..cc39f988df40 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx @@ -46,7 +46,7 @@ function getStartStepIndex(issueNewCard: OnyxEntry): number { function IssueNewCardPage({policy, route}: IssueNewCardPageProps) { const policyID = policy?.id; - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {initWithStoredValues: false}); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const {currentStep} = issueNewCard ?? {}; const backTo = route?.params?.backTo; const {isDelegateAccessRestricted} = useDelegateNoAccessState(); diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx index 941339de9921..6e634ee2a96c 100644 --- a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx @@ -38,7 +38,7 @@ function LimitTypeStep({policy, stepNames, startStepIndex}: LimitTypeStepProps) const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id; - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const formRef = useRef(null); const areApprovalsConfigured = getApprovalWorkflow(policy) !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; diff --git a/src/pages/workspace/expensifyCard/issueNew/SetExpiryOptionsStep.tsx b/src/pages/workspace/expensifyCard/issueNew/SetExpiryOptionsStep.tsx index 78ad0e1bd6f6..1b49efb79427 100644 --- a/src/pages/workspace/expensifyCard/issueNew/SetExpiryOptionsStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/SetExpiryOptionsStep.tsx @@ -33,7 +33,7 @@ function SetExpiryOptionsStep({policy, stepNames, startStepIndex}: SetExpiryOpti const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id; - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); + const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD}${policyID}`); const personalDetails = usePersonalDetails(); const assigneePersonalDetails = Object.values(personalDetails ?? {}).find((detail) => detail?.login === issueNewCard?.data?.assigneeEmail); diff --git a/src/setup/index.ts b/src/setup/index.ts index 4d72fc2816b4..d96e8a0d2d66 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -70,6 +70,7 @@ export default function () { ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, + ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, ], }); From 33d6902a1d31ee2804f285b14bd4d396d299870b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Tue, 21 Apr 2026 15:54:02 +0100 Subject: [PATCH 09/11] Implement useHasFreshWalletData hook --- src/pages/EnablePayments/EnablePayments.tsx | 26 ++--------------- .../EnablePayments/EnablePaymentsPage.tsx | 20 +++---------- .../EnablePayments/useHasFreshWalletData.ts | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 39 deletions(-) create mode 100644 src/pages/EnablePayments/useHasFreshWalletData.ts diff --git a/src/pages/EnablePayments/EnablePayments.tsx b/src/pages/EnablePayments/EnablePayments.tsx index 9b09b1e14e39..282fdf2e33ae 100644 --- a/src/pages/EnablePayments/EnablePayments.tsx +++ b/src/pages/EnablePayments/EnablePayments.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -19,6 +19,7 @@ import AddBankAccount from './AddBankAccount/AddBankAccount'; import FailedKYC from './FailedKYC'; import FeesAndTerms from './FeesAndTerms/FeesAndTerms'; import PersonalInfo from './PersonalInfo/PersonalInfo'; +import useHasFreshWalletData from './useHasFreshWalletData'; import VerifyIdentity from './VerifyIdentity/VerifyIdentity'; function EnablePaymentsPage() { @@ -30,8 +31,7 @@ function EnablePaymentsPage() { const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const paymentCardList = fundList ?? {}; - const wasLoadingRef = useRef(false); - const [hasFreshData, setHasFreshData] = useState(false); + const hasFreshData = useHasFreshWalletData(isOffline, userWallet?.isLoading); useEffect(() => { if (isOffline) { @@ -41,26 +41,6 @@ function EnablePaymentsPage() { openEnablePaymentsPage(); }, [isOffline]); - // Only render step content after the fresh data loading cycle (isLoading: true → false) completes, - // to avoid acting on stale cached values from a previous session. - useEffect(() => { - if (isOffline) { - return; - } - - if (userWallet?.isLoading) { - wasLoadingRef.current = true; - return; - } - - if (!wasLoadingRef.current) { - return; - } - - // eslint-disable-next-line react-hooks/set-state-in-effect -- we need to trigger a re-render when fresh data arrives to stop showing the loading indicator - setHasFreshData(true); - }, [isOffline, userWallet?.isLoading]); - const isUserWalletEmpty = isEmptyObject(userWallet); if (isUserWalletEmpty || userWallet?.isLoading || (!hasFreshData && !isOffline)) { const reasonAttributes: SkeletonSpanReasonAttributes = { diff --git a/src/pages/EnablePayments/EnablePaymentsPage.tsx b/src/pages/EnablePayments/EnablePaymentsPage.tsx index 891c3b21e582..146a40af8087 100644 --- a/src/pages/EnablePayments/EnablePaymentsPage.tsx +++ b/src/pages/EnablePayments/EnablePaymentsPage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect} from 'react'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -18,6 +18,7 @@ import FailedKYC from './FailedKYC'; // Steps import OnfidoStep from './OnfidoStep'; import TermsStep from './TermsStep'; +import useHasFreshWalletData from './useHasFreshWalletData'; function EnablePaymentsPage() { const {translate} = useLocalize(); @@ -25,8 +26,7 @@ function EnablePaymentsPage() { const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); const {isPendingOnfidoResult, hasFailedOnfido} = userWallet ?? {}; - const wasLoadingRef = useRef(false); - const [hasFreshData, setHasFreshData] = useState(false); + const hasFreshData = useHasFreshWalletData(isOffline, userWallet?.isLoading); // Always fetch fresh wallet data on mount useEffect(() => { @@ -44,23 +44,11 @@ function EnablePaymentsPage() { return; } - if (userWallet?.isLoading) { - wasLoadingRef.current = true; - return; - } - - if (!wasLoadingRef.current) { - return; - } - - // eslint-disable-next-line react-hooks/set-state-in-effect -- we need to trigger a re-render when fresh data arrives to stop showing the loading indicator - setHasFreshData(true); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (isPendingOnfidoResult || hasFailedOnfido) { Navigation.navigate(ROUTES.SETTINGS_WALLET, {forceReplace: true}); } - }, [isOffline, isPendingOnfidoResult, hasFailedOnfido, userWallet?.isLoading]); + }, [isOffline, isPendingOnfidoResult, hasFailedOnfido]); const isUserWalletEmpty = isEmptyObject(userWallet); if (isUserWalletEmpty || userWallet?.isLoading || (!hasFreshData && !isOffline)) { diff --git a/src/pages/EnablePayments/useHasFreshWalletData.ts b/src/pages/EnablePayments/useHasFreshWalletData.ts new file mode 100644 index 000000000000..a00b490e58c9 --- /dev/null +++ b/src/pages/EnablePayments/useHasFreshWalletData.ts @@ -0,0 +1,28 @@ +import {useEffect, useRef, useState} from 'react'; + +function useHasFreshWalletData(isOffline: boolean, isLoading: boolean | undefined): boolean { + const wasLoadingRef = useRef(false); + const [hasFreshData, setHasFreshData] = useState(false); + + useEffect(() => { + if (isOffline) { + return; + } + + if (isLoading) { + wasLoadingRef.current = true; + return; + } + + if (!wasLoadingRef.current) { + return; + } + + // eslint-disable-next-line react-hooks/set-state-in-effect -- we need to trigger a re-render when fresh data arrives to stop showing the loading indicator + setHasFreshData(true); + }, [isOffline, isLoading]); + + return hasFreshData; +} + +export default useHasFreshWalletData; From a38329c76b42d0c039556996ff32b948860e56f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henriques?= Date: Fri, 24 Apr 2026 10:53:12 +0100 Subject: [PATCH 10/11] fix: gate EnablePaymentsPage redirect on fresh wallet data --- src/pages/EnablePayments/EnablePaymentsPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/EnablePayments/EnablePaymentsPage.tsx b/src/pages/EnablePayments/EnablePaymentsPage.tsx index 146a40af8087..a54c324ada97 100644 --- a/src/pages/EnablePayments/EnablePaymentsPage.tsx +++ b/src/pages/EnablePayments/EnablePaymentsPage.tsx @@ -40,7 +40,7 @@ function EnablePaymentsPage() { // Only redirect after the fresh data loading cycle (isLoading: true → false) completes, // to avoid acting on stale cached values from a previous session. useEffect(() => { - if (isOffline) { + if (isOffline || !hasFreshData) { return; } @@ -48,7 +48,7 @@ function EnablePaymentsPage() { if (isPendingOnfidoResult || hasFailedOnfido) { Navigation.navigate(ROUTES.SETTINGS_WALLET, {forceReplace: true}); } - }, [isOffline, isPendingOnfidoResult, hasFailedOnfido]); + }, [isOffline, isPendingOnfidoResult, hasFailedOnfido, hasFreshData]); const isUserWalletEmpty = isEmptyObject(userWallet); if (isUserWalletEmpty || userWallet?.isLoading || (!hasFreshData && !isOffline)) { From 6c83ac9322c1f9f1f9ec2712f74de788b0fe0d5d Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Mon, 27 Apr 2026 15:00:46 +0200 Subject: [PATCH 11/11] pr fix --- .../EnablePayments/useHasFreshWalletData.ts | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/pages/EnablePayments/useHasFreshWalletData.ts b/src/pages/EnablePayments/useHasFreshWalletData.ts index a00b490e58c9..1d98c04a9b67 100644 --- a/src/pages/EnablePayments/useHasFreshWalletData.ts +++ b/src/pages/EnablePayments/useHasFreshWalletData.ts @@ -1,28 +1,27 @@ -import {useEffect, useRef, useState} from 'react'; +import {useState} from 'react'; -function useHasFreshWalletData(isOffline: boolean, isLoading: boolean | undefined): boolean { - const wasLoadingRef = useRef(false); - const [hasFreshData, setHasFreshData] = useState(false); - - useEffect(() => { - if (isOffline) { - return; - } - - if (isLoading) { - wasLoadingRef.current = true; - return; - } +type WalletDataState = 'idle' | 'loading' | 'fresh'; - if (!wasLoadingRef.current) { - return; - } - - // eslint-disable-next-line react-hooks/set-state-in-effect -- we need to trigger a re-render when fresh data arrives to stop showing the loading indicator - setHasFreshData(true); - }, [isOffline, isLoading]); +function computeNextState(current: WalletDataState, isOffline: boolean, isLoading: boolean | undefined): WalletDataState { + if (isOffline) { + return current; + } + if (current === 'idle' && isLoading) { + return 'loading'; + } + if (current === 'loading' && !isLoading) { + return 'fresh'; + } + return current; +} - return hasFreshData; +function useHasFreshWalletData(isOffline: boolean, isLoading: boolean | undefined): boolean { + const [state, setState] = useState('idle'); + const next = computeNextState(state, isOffline, isLoading); + if (next !== state) { + setState(next); + } + return next === 'fresh'; } export default useHasFreshWalletData;