From 683b5e321a36406cf7fb96169b8648be3474be76 Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 11 Jun 2026 07:38:56 +0100 Subject: [PATCH 01/14] Reinitialize Pusher during delegate account transitions --- .../AppNavigator/AuthScreensInitHandler.tsx | 15 +---- src/libs/actions/Delegate.ts | 65 +++++++++++++------ src/libs/actions/initializePusher.ts | 20 ++++++ tests/actions/InitializePusherTest.ts | 52 +++++++++++++++ 4 files changed, 118 insertions(+), 34 deletions(-) create mode 100644 src/libs/actions/initializePusher.ts create mode 100644 tests/actions/InitializePusherTest.ts diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index f953488083a9..5b8a0f7d02dd 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -13,7 +13,6 @@ import {init, isClientTheLeader} from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; -import Pusher from '@libs/Pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import {getReportIDFromLink} from '@libs/ReportUtils'; import * as SessionUtils from '@libs/SessionUtils'; @@ -23,24 +22,12 @@ import {openAgentsPage} from '@userActions/Agent'; import * as App from '@userActions/App'; import * as Download from '@userActions/Download'; import {clearStaleExportDownloads} from '@userActions/Export'; +import initializePusher from '@userActions/initializePusher'; import * as Report from '@userActions/Report'; import * as Session from '@userActions/Session'; -import * as User from '@userActions/User'; -import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; - -function initializePusher(currentUserAccountID?: number, currentUserEmail?: string, getReportAttributes?: () => ReportAttributesDerivedValue['reports'] | undefined) { - return Pusher.init({ - appKey: CONFIG.PUSHER.APP_KEY, - cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, - }).then(() => { - User.subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getReportAttributes); - }); -} /** * Component that does not render anything and owns mount-only initialization logic, network reconnect, diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 0325a337fd17..49e16727ca2d 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -9,6 +9,7 @@ import Log from '@libs/Log'; import {clearPreservedSearchNavigatorStates} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import * as NetworkStore from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import Pusher from '@libs/Pusher'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -18,6 +19,7 @@ import type Response from '@src/types/onyx/Response'; import type Session from '@src/types/onyx/Session'; import {confirmReadyToOpenApp, openApp} from './App'; import clearOnyxAndSeedFullReconnect from './clearOnyxAndSeedFullReconnect'; +import initializePusher from './initializePusher'; import updateSessionAuthTokens from './Session/updateSessionAuthTokens'; import updateSessionUser from './Session/updateSessionUser'; @@ -55,6 +57,23 @@ function clearOnyxForDelegateTransition(): Promise { [ONYXKEYS.IS_LOADING_APP]: true, }); } +function initializePusherForDelegateTransition(): Promise { + return new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.SESSION, + callback: (nextSession) => { + if (nextSession?.accountID === undefined) { + return; + } + + const nextAccountID = nextSession.accountID; + + Onyx.disconnect(connection); + initializePusher(nextAccountID, nextSession.email ?? '').finally(resolve); + }, + }); + }); +} type WithDelegatedAccess = { // Optional keeps call sites clean, but still encourages passing `account?.delegatedAccess`. @@ -210,22 +229,25 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, }) .then(() => { NetworkStore.setAuthToken(response?.restrictedToken ?? null); + Pusher.disconnect(); return clearOnyxForDelegateTransition(); }) .then(() => { confirmReadyToOpenApp(); - return openApp().then(() => { - if (!CONFIG.IS_HYBRID_APP || !policyID) { + return openApp() + .then(() => initializePusherForDelegateTransition()) + .then(() => { + if (!CONFIG.IS_HYBRID_APP || !policyID) { + return true; + } + HybridAppModule.switchAccount({ + newDotCurrentAccountEmail: email, + authToken: restrictedToken, + policyID, + accountID: String(session?.accountID ?? CONST.DEFAULT_NUMBER_ID), + }); return true; - } - HybridAppModule.switchAccount({ - newDotCurrentAccountEmail: email, - authToken: restrictedToken, - policyID, - accountID: String(session?.accountID ?? CONST.DEFAULT_NUMBER_ID), }); - return true; - }); }); }) .catch((error) => { @@ -309,6 +331,7 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { }) .then(() => { NetworkStore.setAuthToken(response?.authToken ?? null); + Pusher.disconnect(); return clearOnyxForDelegateTransition(); }) .then(() => { @@ -319,17 +342,19 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { Onyx.set(ONYXKEYS.STASHED_CREDENTIALS, {}); Onyx.set(ONYXKEYS.STASHED_SESSION, {}); confirmReadyToOpenApp(); - openApp().then(() => { - if (!CONFIG.IS_HYBRID_APP) { - return; - } - HybridAppModule.switchAccount({ - newDotCurrentAccountEmail: requesterEmail, - authToken, - policyID: '', - accountID: '', + openApp() + .then(() => initializePusherForDelegateTransition()) + .then(() => { + if (!CONFIG.IS_HYBRID_APP) { + return; + } + HybridAppModule.switchAccount({ + newDotCurrentAccountEmail: requesterEmail, + authToken, + policyID: '', + accountID: '', + }); }); - }); }); }) .catch((error) => { diff --git a/src/libs/actions/initializePusher.ts b/src/libs/actions/initializePusher.ts new file mode 100644 index 000000000000..d97777713601 --- /dev/null +++ b/src/libs/actions/initializePusher.ts @@ -0,0 +1,20 @@ +import Pusher from '@libs/Pusher'; +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; +import type {ReportAttributesDerivedValue} from '@src/types/onyx'; +import {subscribeToUserEvents} from './User'; + +/** + * Shared Pusher bootstrap used by both the normal auth startup flow and delegate transitions. + */ +function initializePusher(currentUserAccountID?: number, currentUserEmail?: string, getReportAttributes?: () => ReportAttributesDerivedValue['reports'] | undefined): Promise { + return Pusher.init({ + appKey: CONFIG.PUSHER.APP_KEY, + cluster: CONFIG.PUSHER.CLUSTER, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, + }).then(() => { + subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getReportAttributes); + }); +} + +export default initializePusher; diff --git a/tests/actions/InitializePusherTest.ts b/tests/actions/InitializePusherTest.ts new file mode 100644 index 000000000000..8534d09cf4ea --- /dev/null +++ b/tests/actions/InitializePusherTest.ts @@ -0,0 +1,52 @@ +import Pusher from '@libs/Pusher'; +import initializePusher from '@userActions/initializePusher'; +import {subscribeToUserEvents} from '@userActions/User'; +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; + +jest.mock('@libs/Pusher', () => ({ + __esModule: true, + default: { + init: jest.fn(() => Promise.resolve()), + }, +})); + +jest.mock('@userActions/User', () => ({ + __esModule: true, + subscribeToUserEvents: jest.fn(), +})); + +describe('actions/initializePusher', () => { + const mockedPusherInit = jest.mocked(Pusher.init); + const mockedSubscribeToUserEvents = jest.mocked(subscribeToUserEvents); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('initializes Pusher and subscribes to the current user events', async () => { + // Verifies the shared helper initializes Pusher with the expected config and forwards the caller context. + const getReportAttributes = jest.fn(); + + await initializePusher(123, 'test@test.com', getReportAttributes); + + expect(mockedPusherInit).toHaveBeenCalledWith({ + appKey: CONFIG.PUSHER.APP_KEY, + cluster: CONFIG.PUSHER.CLUSTER, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, + }); + expect(mockedSubscribeToUserEvents).toHaveBeenCalledWith(123, 'test@test.com', getReportAttributes); + }); + + it('falls back to default account and email values when parameters are not provided', async () => { + // Verifies the helper still subscribes safely when no user context is passed in. + await initializePusher(); + + expect(mockedPusherInit).toHaveBeenCalledWith({ + appKey: CONFIG.PUSHER.APP_KEY, + cluster: CONFIG.PUSHER.CLUSTER, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, + }); + expect(mockedSubscribeToUserEvents).toHaveBeenCalledWith(CONST.DEFAULT_NUMBER_ID, '', undefined); + }); +}); From c8f7a40eff90b79d2580dd278877be6747c08ec2 Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 18 Jun 2026 07:37:35 +0100 Subject: [PATCH 02/14] Set loading state in OnyxUpdateManager test setup --- tests/actions/OnyxUpdateManagerTest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index a7bf9e3e1058..831d72f859d8 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -16,6 +16,7 @@ import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@userActions/OnyxUpdates'); jest.mock('@userActions/App'); @@ -137,8 +138,10 @@ describe('actions/OnyxUpdateManager', () => { beforeEach(async () => { jest.clearAllMocks(); await Onyx.clear(); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1); await Onyx.set(ONYX_KEY, initialData); + await waitForBatchedUpdates(); OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; App.mockValues.missingOnyxUpdatesToBeApplied = undefined; From 21a643401f9ed2d28b512faf4695865c4a3843de Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 18 Jun 2026 09:17:15 +0100 Subject: [PATCH 03/14] Revert OnyxUpdateManager test setup update --- tests/actions/OnyxUpdateManagerTest.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index 831d72f859d8..a7bf9e3e1058 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -16,7 +16,6 @@ import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@userActions/OnyxUpdates'); jest.mock('@userActions/App'); @@ -138,10 +137,8 @@ describe('actions/OnyxUpdateManager', () => { beforeEach(async () => { jest.clearAllMocks(); await Onyx.clear(); - await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1); await Onyx.set(ONYX_KEY, initialData); - await waitForBatchedUpdates(); OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; App.mockValues.missingOnyxUpdatesToBeApplied = undefined; From 706c1b289ec6997f6925873743cd4e33eca8078f Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 18 Jun 2026 14:15:08 +0100 Subject: [PATCH 04/14] Fix OnyxUpdateManager test mock alias mismatch --- tests/actions/OnyxUpdateManagerTest.ts | 13 +++++-------- tests/unit/OnyxUpdateManagerTest.ts | 7 +++++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index a7bf9e3e1058..b67fda5097e9 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -1,8 +1,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; -import type {AppActionsMock} from '@libs/actions/__mocks__/App'; -import * as AppImport from '@libs/actions/App'; +import type {AppActionsMock} from '@userActions/__mocks__/App'; +import * as AppImport from '@userActions/App'; import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably'; import * as OnyxUpdateManagerExports from '@libs/actions/OnyxUpdateManager'; import type {AnyDeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; @@ -18,7 +18,9 @@ import type * as OnyxTypes from '@src/types/onyx'; import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; jest.mock('@userActions/OnyxUpdates'); -jest.mock('@userActions/App'); +// OnyxUpdateManager imports App through mixed aliases, so both aliases need to share the same mock instance. +jest.mock('@userActions/App', () => jest.requireActual('@userActions/__mocks__/App')); +jest.mock('@libs/actions/App', () => jest.requireActual('@userActions/__mocks__/App')); jest.mock('@userActions/OnyxUpdateManager/utils'); jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates', () => { const ApplyUpdatesImplementation = jest.requireActual('@userActions/OnyxUpdateManager/utils/applyUpdates'); @@ -253,16 +255,12 @@ describe('actions/OnyxUpdateManager', () => { // While the fetching of missing updates and the validation and application of the deferred updates is running, // the SequentialQueue should be paused. expect(isSequentialQueuePaused()).toBeTruthy(); - expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); - expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2); }; const assertAfterSecondGetMissingOnyxUpdates = () => { // The SequentialQueue should still be paused. expect(isSequentialQueuePaused()).toBeTruthy(); expect(isSequentialQueueRunning()).toBeFalsy(); - expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); - expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); }; let firstCallFinished = false; @@ -285,7 +283,6 @@ describe('actions/OnyxUpdateManager', () => { // Once the OnyxUpdateManager has finished filling the gaps, the SequentialQueue should be unpaused again. // It must not necessarily be running, because it might not have been flushed yet. expect(isSequentialQueuePaused()).toBeFalsy(); - expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); }); }); }); diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts index f80914a7de9a..b4e0ea1eac72 100644 --- a/tests/unit/OnyxUpdateManagerTest.ts +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -12,8 +12,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; -jest.mock('@userActions/OnyxUpdates'); -jest.mock('@userActions/App'); +// OnyxUpdateManager imports these actions through mixed aliases, so both aliases need to share the same mock instances. +jest.mock('@userActions/OnyxUpdates', () => jest.requireActual('@userActions/__mocks__/OnyxUpdates')); +jest.mock('@libs/actions/OnyxUpdates', () => jest.requireActual('@userActions/__mocks__/OnyxUpdates')); +jest.mock('@userActions/App', () => jest.requireActual('@userActions/__mocks__/App')); +jest.mock('@libs/actions/App', () => jest.requireActual('@userActions/__mocks__/App')); jest.mock('@userActions/OnyxUpdateManager/utils'); jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates'); From c788fd33fa8d3c3a6c1ca466d9926e1aebb61d5a Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 18 Jun 2026 14:22:14 +0100 Subject: [PATCH 05/14] Fixed Prettier --- tests/actions/OnyxUpdateManagerTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index b67fda5097e9..74de51529d49 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -1,8 +1,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; -import type {AppActionsMock} from '@userActions/__mocks__/App'; -import * as AppImport from '@userActions/App'; import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably'; import * as OnyxUpdateManagerExports from '@libs/actions/OnyxUpdateManager'; import type {AnyDeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; @@ -11,6 +9,8 @@ import type {OnyxUpdateManagerUtilsMock} from '@libs/actions/OnyxUpdateManager/u import type {ApplyUpdatesMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; import * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; import {isPaused as isSequentialQueuePaused, isRunning as isSequentialQueueRunning} from '@libs/Network/SequentialQueue'; +import type {AppActionsMock} from '@userActions/__mocks__/App'; +import * as AppImport from '@userActions/App'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; From ec0629fb30f90ba6e13bfe17fc7a9fe479e0428f Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 18 Jun 2026 15:19:15 +0100 Subject: [PATCH 06/14] Fix OnyxUpdateManager mock alias lint errors --- tests/actions/OnyxUpdateManagerTest.ts | 16 +++++++++---- tests/unit/OnyxUpdateManagerTest.ts | 32 +++++++++++++++++++------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index 74de51529d49..f39a03df3d17 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -9,7 +9,7 @@ import type {OnyxUpdateManagerUtilsMock} from '@libs/actions/OnyxUpdateManager/u import type {ApplyUpdatesMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; import * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; import {isPaused as isSequentialQueuePaused, isRunning as isSequentialQueueRunning} from '@libs/Network/SequentialQueue'; -import type {AppActionsMock} from '@userActions/__mocks__/App'; +import type * as AppMockImport from '@userActions/__mocks__/App'; import * as AppImport from '@userActions/App'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -19,8 +19,16 @@ import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; jest.mock('@userActions/OnyxUpdates'); // OnyxUpdateManager imports App through mixed aliases, so both aliases need to share the same mock instance. -jest.mock('@userActions/App', () => jest.requireActual('@userActions/__mocks__/App')); -jest.mock('@libs/actions/App', () => jest.requireActual('@userActions/__mocks__/App')); +jest.mock('@userActions/App', () => { + // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. + const mockApp = jest.requireActual('@userActions/__mocks__/App'); + return mockApp; +}); +jest.mock('@libs/actions/App', () => { + // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. + const mockApp = jest.requireActual('@userActions/__mocks__/App'); + return mockApp; +}); jest.mock('@userActions/OnyxUpdateManager/utils'); jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates', () => { const ApplyUpdatesImplementation = jest.requireActual('@userActions/OnyxUpdateManager/utils/applyUpdates'); @@ -44,7 +52,7 @@ const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = 'testReport1'; const ONYX_KEY = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const; -const App = AppImport as AppActionsMock; +const App = AppImport as AppMockImport.AppActionsMock; const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts index b4e0ea1eac72..a291e7c7a27a 100644 --- a/tests/unit/OnyxUpdateManagerTest.ts +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; -import type {AppActionsMock} from '@userActions/__mocks__/App'; -import type {OnyxUpdatesMock} from '@userActions/__mocks__/OnyxUpdates'; +import type * as AppMockImport from '@userActions/__mocks__/App'; +import type * as OnyxUpdatesMockImport from '@userActions/__mocks__/OnyxUpdates'; import * as AppImport from '@userActions/App'; import * as OnyxUpdateManager from '@userActions/OnyxUpdateManager'; import * as OnyxUpdateManagerUtilsImport from '@userActions/OnyxUpdateManager/utils'; @@ -13,10 +13,26 @@ import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; // OnyxUpdateManager imports these actions through mixed aliases, so both aliases need to share the same mock instances. -jest.mock('@userActions/OnyxUpdates', () => jest.requireActual('@userActions/__mocks__/OnyxUpdates')); -jest.mock('@libs/actions/OnyxUpdates', () => jest.requireActual('@userActions/__mocks__/OnyxUpdates')); -jest.mock('@userActions/App', () => jest.requireActual('@userActions/__mocks__/App')); -jest.mock('@libs/actions/App', () => jest.requireActual('@userActions/__mocks__/App')); +jest.mock('@userActions/OnyxUpdates', () => { + // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. + const mockOnyxUpdates = jest.requireActual('@userActions/__mocks__/OnyxUpdates'); + return mockOnyxUpdates; +}); +jest.mock('@libs/actions/OnyxUpdates', () => { + // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. + const mockOnyxUpdates = jest.requireActual('@userActions/__mocks__/OnyxUpdates'); + return mockOnyxUpdates; +}); +jest.mock('@userActions/App', () => { + // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. + const mockApp = jest.requireActual('@userActions/__mocks__/App'); + return mockApp; +}); +jest.mock('@libs/actions/App', () => { + // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. + const mockApp = jest.requireActual('@userActions/__mocks__/App'); + return mockApp; +}); jest.mock('@userActions/OnyxUpdateManager/utils'); jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates'); @@ -31,8 +47,8 @@ jest.mock('@src/libs/SearchUIUtils', () => ({ })); // eslint-disable-next-line @typescript-eslint/no-explicit-any -const OnyxUpdates = OnyxUpdatesImport as OnyxUpdatesMock; -const App = AppImport as AppActionsMock; +const OnyxUpdates = OnyxUpdatesImport as OnyxUpdatesMockImport.OnyxUpdatesMock; +const App = AppImport as AppMockImport.AppActionsMock; const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; From 076e0dc5ff3ef205f988c5eda691e413b523e7e3 Mon Sep 17 00:00:00 2001 From: Nabi Date: Sat, 20 Jun 2026 07:10:00 +0100 Subject: [PATCH 07/14] Restore stable missing update assertions in OnyxUpdateManager test --- tests/actions/OnyxUpdateManagerTest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index f39a03df3d17..c25784dfff45 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -291,6 +291,9 @@ describe('actions/OnyxUpdateManager', () => { // Once the OnyxUpdateManager has finished filling the gaps, the SequentialQueue should be unpaused again. // It must not necessarily be running, because it might not have been flushed yet. expect(isSequentialQueuePaused()).toBeFalsy(); + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); }); }); }); From 4830e7775fb405965eb2750c66b5489653e0c4e2 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 22 Jun 2026 20:44:51 +0100 Subject: [PATCH 08/14] Revert OnyxUpdateManager test changes --- tests/actions/OnyxUpdateManagerTest.ts | 24 +++++++------------- tests/unit/OnyxUpdateManagerTest.ts | 31 +++++--------------------- 2 files changed, 14 insertions(+), 41 deletions(-) diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index c25784dfff45..a7bf9e3e1058 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -1,6 +1,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; +import type {AppActionsMock} from '@libs/actions/__mocks__/App'; +import * as AppImport from '@libs/actions/App'; import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably'; import * as OnyxUpdateManagerExports from '@libs/actions/OnyxUpdateManager'; import type {AnyDeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; @@ -9,8 +11,6 @@ import type {OnyxUpdateManagerUtilsMock} from '@libs/actions/OnyxUpdateManager/u import type {ApplyUpdatesMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; import * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; import {isPaused as isSequentialQueuePaused, isRunning as isSequentialQueueRunning} from '@libs/Network/SequentialQueue'; -import type * as AppMockImport from '@userActions/__mocks__/App'; -import * as AppImport from '@userActions/App'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -18,17 +18,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; jest.mock('@userActions/OnyxUpdates'); -// OnyxUpdateManager imports App through mixed aliases, so both aliases need to share the same mock instance. -jest.mock('@userActions/App', () => { - // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. - const mockApp = jest.requireActual('@userActions/__mocks__/App'); - return mockApp; -}); -jest.mock('@libs/actions/App', () => { - // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. - const mockApp = jest.requireActual('@userActions/__mocks__/App'); - return mockApp; -}); +jest.mock('@userActions/App'); jest.mock('@userActions/OnyxUpdateManager/utils'); jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates', () => { const ApplyUpdatesImplementation = jest.requireActual('@userActions/OnyxUpdateManager/utils/applyUpdates'); @@ -52,7 +42,7 @@ const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = 'testReport1'; const ONYX_KEY = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const; -const App = AppImport as AppMockImport.AppActionsMock; +const App = AppImport as AppActionsMock; const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; @@ -263,12 +253,16 @@ describe('actions/OnyxUpdateManager', () => { // While the fetching of missing updates and the validation and application of the deferred updates is running, // the SequentialQueue should be paused. expect(isSequentialQueuePaused()).toBeTruthy(); + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2); }; const assertAfterSecondGetMissingOnyxUpdates = () => { // The SequentialQueue should still be paused. expect(isSequentialQueuePaused()).toBeTruthy(); expect(isSequentialQueueRunning()).toBeFalsy(); + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); + expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); }; let firstCallFinished = false; @@ -292,8 +286,6 @@ describe('actions/OnyxUpdateManager', () => { // It must not necessarily be running, because it might not have been flushed yet. expect(isSequentialQueuePaused()).toBeFalsy(); expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); - expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2); - expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); }); }); }); diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts index fd7c087c18fa..5f9148bc2468 100644 --- a/tests/unit/OnyxUpdateManagerTest.ts +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -1,7 +1,7 @@ import Onyx from 'react-native-onyx'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; -import type * as AppMockImport from '@userActions/__mocks__/App'; -import type * as OnyxUpdatesMockImport from '@userActions/__mocks__/OnyxUpdates'; +import type {AppActionsMock} from '@userActions/__mocks__/App'; +import type {OnyxUpdatesMock} from '@userActions/__mocks__/OnyxUpdates'; import * as AppImport from '@userActions/App'; import * as OnyxUpdateManager from '@userActions/OnyxUpdateManager'; import * as OnyxUpdateManagerUtilsImport from '@userActions/OnyxUpdateManager/utils'; @@ -14,27 +14,8 @@ import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -// OnyxUpdateManager imports these actions through mixed aliases, so both aliases need to share the same mock instances. -jest.mock('@userActions/OnyxUpdates', () => { - // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. - const mockOnyxUpdates = jest.requireActual('@userActions/__mocks__/OnyxUpdates'); - return mockOnyxUpdates; -}); -jest.mock('@libs/actions/OnyxUpdates', () => { - // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. - const mockOnyxUpdates = jest.requireActual('@userActions/__mocks__/OnyxUpdates'); - return mockOnyxUpdates; -}); -jest.mock('@userActions/App', () => { - // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. - const mockApp = jest.requireActual('@userActions/__mocks__/App'); - return mockApp; -}); -jest.mock('@libs/actions/App', () => { - // Store the typed mock in a local variable to avoid returning an unsafe `any` from the mock factory. - const mockApp = jest.requireActual('@userActions/__mocks__/App'); - return mockApp; -}); +jest.mock('@userActions/OnyxUpdates'); +jest.mock('@userActions/App'); jest.mock('@userActions/OnyxUpdateManager/utils'); jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates'); @@ -49,8 +30,8 @@ jest.mock('@src/libs/SearchUIUtils', () => ({ })); // eslint-disable-next-line @typescript-eslint/no-explicit-any -const OnyxUpdates = OnyxUpdatesImport as OnyxUpdatesMockImport.OnyxUpdatesMock; -const App = AppImport as AppMockImport.AppActionsMock; +const OnyxUpdates = OnyxUpdatesImport as OnyxUpdatesMock; +const App = AppImport as AppActionsMock; const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; From a2654362a0607727e860207e28de5532e63415d1 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 22 Jun 2026 20:52:44 +0100 Subject: [PATCH 09/14] Break initializePusher User import cycle --- src/libs/actions/initializePusher.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/initializePusher.ts b/src/libs/actions/initializePusher.ts index d97777713601..238dd2519c30 100644 --- a/src/libs/actions/initializePusher.ts +++ b/src/libs/actions/initializePusher.ts @@ -2,7 +2,6 @@ import Pusher from '@libs/Pusher'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import type {ReportAttributesDerivedValue} from '@src/types/onyx'; -import {subscribeToUserEvents} from './User'; /** * Shared Pusher bootstrap used by both the normal auth startup flow and delegate transitions. @@ -12,9 +11,11 @@ function initializePusher(currentUserAccountID?: number, currentUserEmail?: stri appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, - }).then(() => { - subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getReportAttributes); - }); + }).then(() => + import('./User').then(({subscribeToUserEvents}) => { + subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getReportAttributes); + }), + ); } export default initializePusher; From 8912ef3aca9ec35984a42aebbb3f20a288c8d6ba Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 23 Jun 2026 14:35:46 +0100 Subject: [PATCH 10/14] Reinitialize Pusher through AuthScreensInitHandler for delegate switches --- .../AppNavigator/AuthScreensInitHandler.tsx | 30 ++++++++++- src/libs/actions/Delegate.ts | 23 ++------ src/libs/actions/initializePusher.ts | 21 -------- src/libs/requestPusherReinitialize.ts | 13 +++++ tests/actions/InitializePusherTest.ts | 52 ------------------- 5 files changed, 45 insertions(+), 94 deletions(-) delete mode 100644 src/libs/actions/initializePusher.ts create mode 100644 src/libs/requestPusherReinitialize.ts delete mode 100644 tests/actions/InitializePusherTest.ts diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index b2dafbb44731..d328e58eb93e 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -13,20 +13,34 @@ import {init, isClientTheLeader} from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; +import Pusher from '@libs/Pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import {getReportIDFromLink} from '@libs/ReportUtils'; +import {registerPusherReinitializeHandler} from '@libs/requestPusherReinitialize'; import * as SessionUtils from '@libs/SessionUtils'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import {getSearchParamFromUrl} from '@libs/Url'; import * as App from '@userActions/App'; import * as Download from '@userActions/Download'; import {clearStaleExportDownloads} from '@userActions/Export'; -import initializePusher from '@userActions/initializePusher'; import * as Report from '@userActions/Report'; import * as Session from '@userActions/Session'; +import * as User from '@userActions/User'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {ReportAttributesDerivedValue} from '@src/types/onyx'; + +function initializePusher(currentUserAccountID?: number, currentUserEmail?: string, getReportAttributes?: () => ReportAttributesDerivedValue['reports'] | undefined) { + return Pusher.init({ + appKey: CONFIG.PUSHER.APP_KEY, + cluster: CONFIG.PUSHER.CLUSTER, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, + }).then(() => { + User.subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getReportAttributes); + }); +} /** * Component that does not render anything and owns mount-only initialization logic, network reconnect, @@ -62,6 +76,20 @@ function AuthScreensInitHandler() { useReconcileHighContrastIntent(); + useEffect(() => { + registerPusherReinitializeHandler(() => { + if (session?.accountID === undefined) { + return Promise.resolve(); + } + + return initializePusher(session.accountID, session.email, () => reportAttributesRef.current); + }); + + return () => { + registerPusherReinitializeHandler(null); + }; + }, [session?.accountID, session?.email]); + useEffect(() => { if (!Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) { return; diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index a16daadc2f34..e5b15fa1efdb 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -15,6 +15,7 @@ import {clearPreservedSearchNavigatorStates} from '@libs/Navigation/AppNavigator import * as NetworkStore from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import Pusher from '@libs/Pusher'; +import {requestPusherReinitialize} from '@libs/requestPusherReinitialize'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -24,7 +25,6 @@ import type Response from '@src/types/onyx/Response'; import type Session from '@src/types/onyx/Session'; import {confirmReadyToOpenApp, openApp} from './App'; import clearOnyxAndSeedFullReconnect from './clearOnyxAndSeedFullReconnect'; -import initializePusher from './initializePusher'; import updateSessionAuthTokens from './Session/updateSessionAuthTokens'; import updateSessionUser from './Session/updateSessionUser'; @@ -62,23 +62,6 @@ function clearOnyxForDelegateTransition(): Promise { [ONYXKEYS.IS_LOADING_APP]: true, }); } -function initializePusherForDelegateTransition(): Promise { - return new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: ONYXKEYS.SESSION, - callback: (nextSession) => { - if (nextSession?.accountID === undefined) { - return; - } - - const nextAccountID = nextSession.accountID; - - Onyx.disconnect(connection); - initializePusher(nextAccountID, nextSession.email ?? '').finally(resolve); - }, - }); - }); -} type WithDelegatedAccess = { // Optional keeps call sites clean, but still encourages passing `account?.delegatedAccess`. @@ -240,7 +223,7 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, .then(() => { confirmReadyToOpenApp(); return openApp() - .then(() => initializePusherForDelegateTransition()) + .then(() => requestPusherReinitialize()) .then(() => { if (!CONFIG.IS_HYBRID_APP || !policyID) { return true; @@ -348,7 +331,7 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { Onyx.set(ONYXKEYS.STASHED_SESSION, {}); confirmReadyToOpenApp(); openApp() - .then(() => initializePusherForDelegateTransition()) + .then(() => requestPusherReinitialize()) .then(() => { if (!CONFIG.IS_HYBRID_APP) { return; diff --git a/src/libs/actions/initializePusher.ts b/src/libs/actions/initializePusher.ts deleted file mode 100644 index 238dd2519c30..000000000000 --- a/src/libs/actions/initializePusher.ts +++ /dev/null @@ -1,21 +0,0 @@ -import Pusher from '@libs/Pusher'; -import CONFIG from '@src/CONFIG'; -import CONST from '@src/CONST'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx'; - -/** - * Shared Pusher bootstrap used by both the normal auth startup flow and delegate transitions. - */ -function initializePusher(currentUserAccountID?: number, currentUserEmail?: string, getReportAttributes?: () => ReportAttributesDerivedValue['reports'] | undefined): Promise { - return Pusher.init({ - appKey: CONFIG.PUSHER.APP_KEY, - cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, - }).then(() => - import('./User').then(({subscribeToUserEvents}) => { - subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getReportAttributes); - }), - ); -} - -export default initializePusher; diff --git a/src/libs/requestPusherReinitialize.ts b/src/libs/requestPusherReinitialize.ts new file mode 100644 index 000000000000..24d7a6c38ae5 --- /dev/null +++ b/src/libs/requestPusherReinitialize.ts @@ -0,0 +1,13 @@ +type PusherReinitializeHandler = () => Promise; + +let registeredHandler: PusherReinitializeHandler | null = null; + +function registerPusherReinitializeHandler(handler: PusherReinitializeHandler | null) { + registeredHandler = handler; +} + +function requestPusherReinitialize(): Promise { + return registeredHandler?.() ?? Promise.resolve(); +} + +export {registerPusherReinitializeHandler, requestPusherReinitialize}; diff --git a/tests/actions/InitializePusherTest.ts b/tests/actions/InitializePusherTest.ts deleted file mode 100644 index 8534d09cf4ea..000000000000 --- a/tests/actions/InitializePusherTest.ts +++ /dev/null @@ -1,52 +0,0 @@ -import Pusher from '@libs/Pusher'; -import initializePusher from '@userActions/initializePusher'; -import {subscribeToUserEvents} from '@userActions/User'; -import CONFIG from '@src/CONFIG'; -import CONST from '@src/CONST'; - -jest.mock('@libs/Pusher', () => ({ - __esModule: true, - default: { - init: jest.fn(() => Promise.resolve()), - }, -})); - -jest.mock('@userActions/User', () => ({ - __esModule: true, - subscribeToUserEvents: jest.fn(), -})); - -describe('actions/initializePusher', () => { - const mockedPusherInit = jest.mocked(Pusher.init); - const mockedSubscribeToUserEvents = jest.mocked(subscribeToUserEvents); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('initializes Pusher and subscribes to the current user events', async () => { - // Verifies the shared helper initializes Pusher with the expected config and forwards the caller context. - const getReportAttributes = jest.fn(); - - await initializePusher(123, 'test@test.com', getReportAttributes); - - expect(mockedPusherInit).toHaveBeenCalledWith({ - appKey: CONFIG.PUSHER.APP_KEY, - cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, - }); - expect(mockedSubscribeToUserEvents).toHaveBeenCalledWith(123, 'test@test.com', getReportAttributes); - }); - - it('falls back to default account and email values when parameters are not provided', async () => { - // Verifies the helper still subscribes safely when no user context is passed in. - await initializePusher(); - - expect(mockedPusherInit).toHaveBeenCalledWith({ - appKey: CONFIG.PUSHER.APP_KEY, - cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, - }); - expect(mockedSubscribeToUserEvents).toHaveBeenCalledWith(CONST.DEFAULT_NUMBER_ID, '', undefined); - }); -}); From d2ab2d40fc385285cdb5fbcb1eb1cf2cd744f30a Mon Sep 17 00:00:00 2001 From: Nabi Date: Sat, 27 Jun 2026 15:49:50 +0100 Subject: [PATCH 11/14] Prevent stale Pusher reinitialization during delegate transitions --- .../AppNavigator/AuthScreensInitHandler.tsx | 10 +++++++--- src/libs/actions/Delegate.ts | 4 ++-- src/libs/requestPusherReinitialize.ts | 12 +++++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index d328e58eb93e..23b986838e78 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -17,6 +17,7 @@ import Pusher from '@libs/Pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import {getReportIDFromLink} from '@libs/ReportUtils'; import {registerPusherReinitializeHandler} from '@libs/requestPusherReinitialize'; +import type {PusherReinitializeHandlerParams} from '@libs/requestPusherReinitialize'; import * as SessionUtils from '@libs/SessionUtils'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import {getSearchParamFromUrl} from '@libs/Url'; @@ -77,12 +78,15 @@ function AuthScreensInitHandler() { useReconcileHighContrastIntent(); useEffect(() => { - registerPusherReinitializeHandler(() => { - if (session?.accountID === undefined) { + registerPusherReinitializeHandler(({accountID, email}: PusherReinitializeHandlerParams = {}) => { + const currentAccountID = accountID ?? session?.accountID; + const currentEmail = email ?? session?.email ?? ''; + + if (currentAccountID === undefined) { return Promise.resolve(); } - return initializePusher(session.accountID, session.email, () => reportAttributesRef.current); + return initializePusher(currentAccountID, currentEmail, () => reportAttributesRef.current); }); return () => { diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 7cfe542879c0..0a972e889ae8 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -223,7 +223,7 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, .then(() => { confirmReadyToOpenApp(); return openApp() - .then(() => requestPusherReinitialize()) + .then(() => requestPusherReinitialize({accountID: response.accountID, email: response.email})) .then(() => { if (!CONFIG.IS_HYBRID_APP || !policyID) { return true; @@ -331,7 +331,7 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { Onyx.set(ONYXKEYS.STASHED_SESSION, {}); confirmReadyToOpenApp(); openApp() - .then(() => requestPusherReinitialize()) + .then(() => requestPusherReinitialize({accountID: response.requesterID, email: requesterEmail})) .then(() => { if (!CONFIG.IS_HYBRID_APP) { return; diff --git a/src/libs/requestPusherReinitialize.ts b/src/libs/requestPusherReinitialize.ts index 24d7a6c38ae5..bbd3ef2fed33 100644 --- a/src/libs/requestPusherReinitialize.ts +++ b/src/libs/requestPusherReinitialize.ts @@ -1,4 +1,9 @@ -type PusherReinitializeHandler = () => Promise; +type PusherReinitializeHandlerParams = { + accountID?: number; + email?: string; +}; + +type PusherReinitializeHandler = (params?: PusherReinitializeHandlerParams) => Promise; let registeredHandler: PusherReinitializeHandler | null = null; @@ -6,8 +11,9 @@ function registerPusherReinitializeHandler(handler: PusherReinitializeHandler | registeredHandler = handler; } -function requestPusherReinitialize(): Promise { - return registeredHandler?.() ?? Promise.resolve(); +function requestPusherReinitialize(params?: PusherReinitializeHandlerParams): Promise { + return registeredHandler?.(params) ?? Promise.resolve(); } export {registerPusherReinitializeHandler, requestPusherReinitialize}; +export type {PusherReinitializeHandlerParams}; From a828d1dba2c996bc7a3eea66f9caf8139de15c7c Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 1 Jul 2026 07:39:03 +0430 Subject: [PATCH 12/14] Log missing Pusher reinit handler during delegate transitions --- src/libs/requestPusherReinitialize.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/libs/requestPusherReinitialize.ts b/src/libs/requestPusherReinitialize.ts index bbd3ef2fed33..c02f7a1a4c45 100644 --- a/src/libs/requestPusherReinitialize.ts +++ b/src/libs/requestPusherReinitialize.ts @@ -1,3 +1,5 @@ +import Log from '@libs/Log'; + type PusherReinitializeHandlerParams = { accountID?: number; email?: string; @@ -12,7 +14,12 @@ function registerPusherReinitializeHandler(handler: PusherReinitializeHandler | } function requestPusherReinitialize(params?: PusherReinitializeHandlerParams): Promise { - return registeredHandler?.(params) ?? Promise.resolve(); + if (!registeredHandler) { + Log.warn('[requestPusherReinitialize] No handler registered, skipping Pusher reinit', {params}); + return Promise.resolve(); + } + + return registeredHandler(params); } export {registerPusherReinitializeHandler, requestPusherReinitialize}; From 56354eb8c50871ad512ebb4fa7f38374257789e5 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 1 Jul 2026 07:44:17 +0430 Subject: [PATCH 13/14] Log missing Pusher reinitialization handler --- src/libs/requestPusherReinitialize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/requestPusherReinitialize.ts b/src/libs/requestPusherReinitialize.ts index c02f7a1a4c45..75b13bbe2f03 100644 --- a/src/libs/requestPusherReinitialize.ts +++ b/src/libs/requestPusherReinitialize.ts @@ -15,7 +15,7 @@ function registerPusherReinitializeHandler(handler: PusherReinitializeHandler | function requestPusherReinitialize(params?: PusherReinitializeHandlerParams): Promise { if (!registeredHandler) { - Log.warn('[requestPusherReinitialize] No handler registered, skipping Pusher reinit', {params}); + Log.warn('[requestPusherReinitialize] No handler registered, skipping Pusher reinitialization', {params}); return Promise.resolve(); } From a70d63b8dd3242ee134e21c05825984dad6b229c Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 1 Jul 2026 08:25:04 +0430 Subject: [PATCH 14/14] Fix Log import path in requestPusherReinitialize --- src/libs/requestPusherReinitialize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/requestPusherReinitialize.ts b/src/libs/requestPusherReinitialize.ts index 75b13bbe2f03..d9fdefdbb284 100644 --- a/src/libs/requestPusherReinitialize.ts +++ b/src/libs/requestPusherReinitialize.ts @@ -1,4 +1,4 @@ -import Log from '@libs/Log'; +import Log from './Log'; type PusherReinitializeHandlerParams = { accountID?: number;