Skip to content

Commit 14a7d1b

Browse files
authored
Merge pull request Expensify#89254 from callstack-internal/perf/fix-doubled-OpenApp
2 parents 31e9f3b + 775ea64 commit 14a7d1b

5 files changed

Lines changed: 178 additions & 9 deletions

File tree

src/libs/NetworkState.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let unsubscribeNetInfo: (() => void) | null = null;
2020
let prevIsInternetReachable: boolean | null | undefined;
2121
let isPoorConnectionSimulated: boolean | undefined;
2222
let networkTimeSkew = 0;
23+
let suppressNextReachabilityRestored = false;
2324

2425
// Subscriber sets
2526
const listeners = new Set<() => void>();
@@ -268,11 +269,24 @@ function setRandomNetworkStatus(initialCall = false) {
268269
* Must unsubscribe before calling configure() — configure tears down NetInfo internal state.
269270
*/
270271
function configureAndSubscribe() {
272+
// Treat this as a reconfigure (not an initial subscription) when there's already a listener.
273+
// Reconfigure tears down NetInfo internal state, so the new subscription emits a synthetic
274+
// null→true transition that would look like a recovery — suppress the next would-be recovery
275+
// until reachability settles. Initial subscription is left untouched so boot behavior is
276+
// unchanged (prev=undefined boot guard already covers it).
277+
// Skip suppression when prev was already false: the app was genuinely offline before
278+
// reconfigure, so the next true is a real recovery we must not drop (otherwise
279+
// internetUnreachable stays set and the app is stuck offline until a new outage cycle).
280+
const isReconfigure = unsubscribeNetInfo !== null;
271281
if (unsubscribeNetInfo) {
272282
unsubscribeNetInfo();
273283
unsubscribeNetInfo = null;
274284
}
275285

286+
if (isReconfigure && prevIsInternetReachable !== false) {
287+
suppressNextReachabilityRestored = true;
288+
}
289+
276290
if (!CONFIG.IS_USING_LOCAL_WEB) {
277291
NetInfo.configure({
278292
reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/Ping?accountID=${accountID ?? 'unknown'}`,
@@ -315,8 +329,17 @@ function configureAndSubscribe() {
315329
// NetInfo event on subscribe which delivers current state, not a recovery. Firing
316330
// onReachabilityRestored() on boot would duplicate openApp()/reconnectApp().
317331
if (!shouldForceOffline && state.isInternetReachable === true && prevIsInternetReachable !== true && prevIsInternetReachable !== undefined) {
318-
Log.info(`[NetworkState] Internet reachability restored (${prevIsInternetReachable}→true)`);
319-
onReachabilityRestored();
332+
if (suppressNextReachabilityRestored) {
333+
Log.info(`[NetworkState] Suppressing recovery on first stable state after reconfigure (${prevIsInternetReachable}→true)`);
334+
} else {
335+
Log.info(`[NetworkState] Internet reachability restored (${prevIsInternetReachable}→true)`);
336+
onReachabilityRestored();
337+
}
338+
}
339+
// End the post-reconfigure suppression window once reachability settles into a definitive
340+
// state. Null/undefined are transient and should not end the window.
341+
if (state.isInternetReachable === true || state.isInternetReachable === false) {
342+
suppressNextReachabilityRestored = false;
320343
}
321344
prevIsInternetReachable = state.isInternetReachable;
322345
});

src/libs/actions/Delegate.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [
4141
ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS,
4242
];
4343

44+
/**
45+
* Atomically reset Onyx for a delegate-access transition while keeping IS_LOADING_APP=true
46+
* so consumers never observe the post-clear state with HAS_LOADED_APP=true and
47+
* IS_LOADING_APP=undefined. That combination falsely looks like a stuck app and triggers
48+
* DelegateAccessHandler's recovery effect, producing a duplicate openApp queued behind the
49+
* explicit openApp the caller is about to make.
50+
*/
51+
function clearOnyxForDelegateTransition(): Promise<void> {
52+
return Onyx.merge(ONYXKEYS.IS_LOADING_APP, true).then(() => Onyx.clear([...KEYS_TO_PRESERVE_DELEGATE_ACCESS, ONYXKEYS.IS_LOADING_APP]));
53+
}
54+
4455
type WithDelegatedAccess = {
4556
// Optional keeps call sites clean, but still encourages passing `account?.delegatedAccess`.
4657
delegatedAccess: DelegatedAccess | undefined;
@@ -195,7 +206,7 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID,
195206
})
196207
.then(() => {
197208
NetworkStore.setAuthToken(response?.restrictedToken ?? null);
198-
return Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS);
209+
return clearOnyxForDelegateTransition();
199210
})
200211
.then(() => {
201212
confirmReadyToOpenApp();
@@ -294,7 +305,7 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) {
294305
})
295306
.then(() => {
296307
NetworkStore.setAuthToken(response?.authToken ?? null);
297-
return Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS);
308+
return clearOnyxForDelegateTransition();
298309
})
299310
.then(() => {
300311
Onyx.set(ONYXKEYS.CREDENTIALS, {
@@ -663,7 +674,7 @@ function updateDelegateRole({email, role, validateCode, delegatedAccess}: Update
663674
}
664675

665676
function restoreDelegateSession<TKey extends OnyxKey>(authenticateResponse: Response<TKey>) {
666-
Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS).then(() => {
677+
clearOnyxForDelegateTransition().then(() => {
667678
updateSessionAuthTokens(authenticateResponse?.authToken, authenticateResponse?.encryptedAuthToken);
668679
updateSessionUser(authenticateResponse?.accountID, authenticateResponse?.email);
669680

@@ -690,5 +701,6 @@ export {
690701
updateDelegateRole,
691702
removeDelegate,
692703
openSecuritySettingsPage,
704+
clearOnyxForDelegateTransition,
693705
KEYS_TO_PRESERVE_DELEGATE_ACCESS,
694706
};

src/libs/actions/Session/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import Timers from '@libs/Timers';
5151
import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu';
5252
import {confirmReadyToOpenApp, KEYS_TO_PRESERVE, openApp} from '@userActions/App';
5353
import {clearCachedAttachments} from '@userActions/Attachment';
54-
import {KEYS_TO_PRESERVE_DELEGATE_ACCESS} from '@userActions/Delegate';
54+
import {clearOnyxForDelegateTransition} from '@userActions/Delegate';
5555
import * as Device from '@userActions/Device';
5656
import type HybridAppSettings from '@userActions/HybridApp/types';
5757
import {close} from '@userActions/Modal';
@@ -718,7 +718,7 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin
718718
}
719719

720720
Log.info('[HybridApp] User switched account on OldDot side. Clearing onyx and applying delegate data');
721-
return Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS)
721+
return clearOnyxForDelegateTransition()
722722
.then(() =>
723723
Onyx.multiSet({
724724
...stashedData,

tests/actions/DelegateTest.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import Onyx from 'react-native-onyx';
2-
import {addDelegate, clearDelegateErrorsByField, clearDelegatorErrors, isConnectedAsDelegate, removeDelegate, updateDelegateRole} from '@libs/actions/Delegate';
2+
import {
3+
addDelegate,
4+
clearDelegateErrorsByField,
5+
clearDelegatorErrors,
6+
clearOnyxForDelegateTransition,
7+
isConnectedAsDelegate,
8+
removeDelegate,
9+
updateDelegateRole,
10+
} from '@libs/actions/Delegate';
311
import {pause, resetQueue} from '@libs/Network/SequentialQueue';
412
import CONST from '@src/CONST';
513
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
@@ -236,4 +244,54 @@ describe('actions/Delegate', () => {
236244
});
237245
});
238246
});
247+
248+
describe('clearOnyxForDelegateTransition', () => {
249+
it('keeps IS_LOADING_APP=true after the clear so DelegateAccessHandler does not see HAS_LOADED_APP=true && IS_LOADING_APP=undefined and fire a duplicate openApp', async () => {
250+
// Simulate the pre-switch state: app is fully loaded with the previous account.
251+
await Onyx.multiSet({
252+
[ONYXKEYS.HAS_LOADED_APP]: true,
253+
[ONYXKEYS.IS_LOADING_APP]: false,
254+
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
255+
});
256+
await waitForBatchedUpdates();
257+
258+
await clearOnyxForDelegateTransition();
259+
await waitForBatchedUpdates();
260+
261+
await new Promise<void>((resolve) => {
262+
const conn = Onyx.connect({
263+
key: ONYXKEYS.IS_LOADING_APP,
264+
callback: (value) => {
265+
expect(value).toBe(true);
266+
Onyx.disconnect(conn);
267+
resolve();
268+
},
269+
});
270+
});
271+
272+
// HAS_LOADED_APP is in the preserve list and should remain true.
273+
await new Promise<void>((resolve) => {
274+
const conn = Onyx.connect({
275+
key: ONYXKEYS.HAS_LOADED_APP,
276+
callback: (value) => {
277+
expect(value).toBe(true);
278+
Onyx.disconnect(conn);
279+
resolve();
280+
},
281+
});
282+
});
283+
284+
// A non-preserved key should have been cleared.
285+
await new Promise<void>((resolve) => {
286+
const conn = Onyx.connect({
287+
key: ONYXKEYS.NVP_PRIORITY_MODE,
288+
callback: (value) => {
289+
expect(value).toBeUndefined();
290+
Onyx.disconnect(conn);
291+
resolve();
292+
},
293+
});
294+
});
295+
});
296+
});
239297
});

tests/unit/NetworkStateReachabilityTest.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {NetInfoState} from '@react-native-community/netinfo';
22
import type * as NetworkState from '@src/libs/NetworkState';
33

44
let netInfoListener: ((state: NetInfoState) => void) | null = null;
5+
const mockOnyxCallbacks = new Map<string, (value: unknown) => void>();
56

67
jest.mock('@react-native-community/netinfo', () => ({
78
addEventListener: jest.fn((cb: (state: NetInfoState) => void) => {
@@ -17,7 +18,9 @@ jest.mock('@react-native-community/netinfo', () => ({
1718

1819
jest.mock('@src/libs/Log');
1920
jest.mock('react-native-onyx', () => ({
20-
connectWithoutView: jest.fn(),
21+
connectWithoutView: jest.fn(({key, callback}: {key: string; callback: (value: unknown) => void}) => {
22+
mockOnyxCallbacks.set(key, callback);
23+
}),
2124
}));
2225

2326
function fireNetInfoState(overrides: Partial<NetInfoState>) {
@@ -33,12 +36,21 @@ function fireNetInfoState(overrides: Partial<NetInfoState>) {
3336
} as NetInfoState);
3437
}
3538

39+
function fireSessionChange(accountID: number) {
40+
const cb = mockOnyxCallbacks.get('session');
41+
if (!cb) {
42+
throw new Error('SESSION callback not registered');
43+
}
44+
cb({accountID});
45+
}
46+
3647
describe('NetworkState — internetUnreachable hard stop via NetInfo', () => {
3748
let getIsOffline: typeof NetworkState.getIsOffline;
3849

3950
beforeEach(() => {
4051
jest.resetModules();
4152
netInfoListener = null;
53+
mockOnyxCallbacks.clear();
4254

4355
const mod = require<typeof NetworkState>('@src/libs/NetworkState');
4456
getIsOffline = mod.getIsOffline;
@@ -81,6 +93,7 @@ describe('NetworkState — reachability recovery triggers reconnect', () => {
8193
beforeEach(() => {
8294
jest.resetModules();
8395
netInfoListener = null;
96+
mockOnyxCallbacks.clear();
8497

8598
// Fresh import each test so prevIsInternetReachable resets
8699
const mod = require<typeof NetworkState>('@src/libs/NetworkState');
@@ -143,6 +156,69 @@ describe('NetworkState — reachability recovery triggers reconnect', () => {
143156
expect(reconnectListener).not.toHaveBeenCalled();
144157
});
145158

159+
test('SESSION accountID change does NOT fire reconnect listener via the post-reconfigure synthetic transition', () => {
160+
// Repro for the doubled-OpenApp bug on delegate switch: the SESSION accountID change
161+
// re-runs configureAndSubscribe(), which tears down and re-subscribes to NetInfo. The
162+
// new subscription emits null then true, which would look like a recovery. The fix
163+
// resets prev to undefined on reconfigure so the new subscription's first transitions
164+
// are treated like boot, not recovery.
165+
const reconnectListener = jest.fn();
166+
onReachabilityConfirmed(reconnectListener);
167+
168+
// Establish a baseline reachable state (boot)
169+
fireNetInfoState({isInternetReachable: true});
170+
expect(reconnectListener).not.toHaveBeenCalled();
171+
172+
// Delegate switch: SESSION accountID changes → reconfigure → new NetInfo subscription
173+
fireSessionChange(42);
174+
175+
// New subscription's initial events: null while the first Ping is in flight, then true
176+
fireNetInfoState({isInternetReachable: null});
177+
fireNetInfoState({isInternetReachable: true});
178+
179+
expect(reconnectListener).not.toHaveBeenCalled();
180+
});
181+
182+
test('genuine offline→online after a SESSION reconfigure still fires reconnect listener', () => {
183+
// Make sure the reconfigure suppression doesn't swallow real recoveries that happen
184+
// afterwards — only the synthetic post-reconfigure transition should be ignored.
185+
const reconnectListener = jest.fn();
186+
onReachabilityConfirmed(reconnectListener);
187+
188+
fireNetInfoState({isInternetReachable: true});
189+
fireSessionChange(42);
190+
191+
// Settle on the reconfigured subscription
192+
fireNetInfoState({isInternetReachable: true});
193+
expect(reconnectListener).not.toHaveBeenCalled();
194+
195+
// Now a real outage and recovery
196+
fireNetInfoState({isInternetReachable: false});
197+
fireNetInfoState({isInternetReachable: true});
198+
199+
expect(reconnectListener).toHaveBeenCalledTimes(1);
200+
});
201+
202+
test('genuine offline before reconfigure still recovers on the next true', () => {
203+
// Boot offline scenario: NetInfo confirms unreachable BEFORE SESSION hydrates and triggers
204+
// a reconfigure. The post-reconfigure true must NOT be suppressed — otherwise the app would
205+
// remain stuck with internetUnreachable=true until a brand new outage cycle.
206+
const reconnectListener = jest.fn();
207+
onReachabilityConfirmed(reconnectListener);
208+
209+
// Cold boot: null then false → app is genuinely offline, prev=false
210+
fireNetInfoState({isInternetReachable: null});
211+
fireNetInfoState({isInternetReachable: false});
212+
213+
// SESSION hydrates → reconfigure happens while we are still offline
214+
fireSessionChange(42);
215+
216+
// New subscription's first definitive event recovers
217+
fireNetInfoState({isInternetReachable: true});
218+
219+
expect(reconnectListener).toHaveBeenCalledTimes(1);
220+
});
221+
146222
test('turning off force-offline resets prevIsInternetReachable so next refresh triggers reconnect', () => {
147223
const reconnectListener = jest.fn();
148224
onReachabilityConfirmed(reconnectListener);

0 commit comments

Comments
 (0)