Skip to content

Commit 8cd7332

Browse files
authored
Merge pull request Expensify#89980 from callstack-internal/perf/seed-full-reconnect-on-onyx-clear
2 parents 842c9b7 + 1170814 commit 8cd7332

5 files changed

Lines changed: 231 additions & 19 deletions

File tree

src/libs/actions/App.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import ROUTES from '@src/ROUTES';
2626
import type * as OnyxTypes from '@src/types/onyx';
2727
import type Locale from '@src/types/onyx/Locale';
2828
import type {OnyxData} from '@src/types/onyx/Request';
29+
import clearOnyxAndSeedFullReconnect from './clearOnyxAndSeedFullReconnect';
2930
import {setShouldForceOffline} from './Network';
3031
import {getAll, rollbackOngoingRequest, save} from './PersistedRequests';
3132
import {createDraftInitialWorkspace, createWorkspace, generateDefaultWorkspaceName, generatePolicyID} from './Policy/Policy';
@@ -850,8 +851,10 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) {
850851
const sequentialQueue = getAll();
851852

852853
Navigation.clearPreloadedRoutes();
854+
// Seed LAST_FULL_RECONNECT_TIME so subscribeToFullReconnect doesn't fire a duplicate
855+
// ReconnectApp once the openApp() below lands NVP_RECONNECT_APP_IF_FULL_RECONNECT_BEFORE.
853856
const resetPromise = clearWorkboxRecoveryCaches().then(() =>
854-
Onyx.clear(KEYS_TO_PRESERVE)
857+
clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE)
855858
.then(() => {
856859
// Network key is preserved, so when exiting imported state, we should:
857860
// 1. Stop forcing offline mode so the app can reconnect

src/libs/actions/Delegate.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type {OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx';
44
import * as API from '@libs/API';
55
import type {AddDelegateParams as APIAddDelegateParams, RemoveDelegateParams as APIRemoveDelegateParams, UpdateDelegateRoleParams as APIUpdateDelegateRoleParams} from '@libs/API/parameters';
66
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
7-
import DateUtils from '@libs/DateUtils';
87
import * as ErrorUtils from '@libs/ErrorUtils';
98
import Log from '@libs/Log';
109
import {clearPreservedSearchNavigatorStates} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState';
@@ -18,6 +17,7 @@ import type Credentials from '@src/types/onyx/Credentials';
1817
import type Response from '@src/types/onyx/Response';
1918
import type Session from '@src/types/onyx/Session';
2019
import {confirmReadyToOpenApp, openApp} from './App';
20+
import clearOnyxAndSeedFullReconnect from './clearOnyxAndSeedFullReconnect';
2121
import updateSessionAuthTokens from './Session/updateSessionAuthTokens';
2222
import updateSessionUser from './Session/updateSessionUser';
2323

@@ -43,25 +43,17 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [
4343
];
4444

4545
/**
46-
* Atomically reset Onyx for a delegate-access transition while seeding two values that
47-
* subscribers would otherwise misinterpret on the post-clear state and double up calls
48-
* the caller is about to make explicitly:
46+
* Atomically reset Onyx for a delegate-access transition. The IS_LOADING_APP=true
47+
* seed is delegate-specific: without it, consumers observe HAS_LOADED_APP=true and
48+
* IS_LOADING_APP=undefined together, which looks like a stuck app and triggers
49+
* DelegateAccessHandler's recovery effect, queueing a duplicate openApp.
4950
*
50-
* - IS_LOADING_APP=true: without it, consumers observe HAS_LOADED_APP=true and
51-
* IS_LOADING_APP=undefined together, which looks like a stuck app and triggers
52-
* DelegateAccessHandler's recovery effect, queueing a duplicate openApp.
53-
* - LAST_FULL_RECONNECT_TIME=now: subscribeToFullReconnect compares this against the
54-
* server-supplied NVP_RECONNECT_APP_IF_FULL_RECONNECT_BEFORE that lands in OpenApp's
55-
* response.onyxData. Because applyHTTPSOnyxUpdates applies response.onyxData before
56-
* successData, the timestamp would still be empty when the comparison runs, falsely
57-
* triggering a duplicate ReconnectApp. Seeding to `now` short-circuits the subscriber
58-
* until OpenApp's successData refreshes it.
51+
* The reconnect-time seed is handled by clearOnyxAndSeedFullReconnect.
5952
*/
6053
function clearOnyxForDelegateTransition(): Promise<void> {
61-
return Onyx.multiSet({
54+
return clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE_DELEGATE_ACCESS, {
6255
[ONYXKEYS.IS_LOADING_APP]: true,
63-
[ONYXKEYS.LAST_FULL_RECONNECT_TIME]: DateUtils.getDBTime(),
64-
}).then(() => Onyx.clear([...KEYS_TO_PRESERVE_DELEGATE_ACCESS, ONYXKEYS.IS_LOADING_APP, ONYXKEYS.LAST_FULL_RECONNECT_TIME]));
56+
});
6557
}
6658

6759
type WithDelegatedAccess = {

src/libs/actions/Session/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +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 clearOnyxAndSeedFullReconnect from '@userActions/clearOnyxAndSeedFullReconnect';
5455
import {clearOnyxForDelegateTransition} from '@userActions/Delegate';
5556
import * as Device from '@userActions/Device';
5657
import type HybridAppSettings from '@userActions/HybridApp/types';
@@ -448,7 +449,9 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess
448449
});
449450
} else if (shouldRestoreStashedSession && !shouldStashSession && hasStashedSession(stashedSession, stashedCredentials)) {
450451
// Preserve SESSION during clear to avoid a login page flash, then restore the stashed session.
451-
Onyx.clear(KEYS_TO_PRESERVE_SUPPORTAL).then(() => {
452+
// Seed LAST_FULL_RECONNECT_TIME so subscribeToFullReconnect doesn't fire a duplicate
453+
// ReconnectApp once the openApp() below lands NVP_RECONNECT_APP_IF_FULL_RECONNECT_BEFORE.
454+
clearOnyxAndSeedFullReconnect(KEYS_TO_PRESERVE_SUPPORTAL).then(() => {
452455
Onyx.multiSet(onyxSetParams).then(() => {
453456
Onyx.set(ONYXKEYS.STASHED_CREDENTIALS, {});
454457
Onyx.set(ONYXKEYS.STASHED_SESSION, {});
@@ -1328,7 +1331,7 @@ function validateTwoFactorAuth(twoFactorAuthCode: string, shouldClearData: boole
13281331
// Clear onyx data if the user has just signed in and is forced to add 2FA
13291332
if (shouldClearData) {
13301333
const keysToPreserveWithPrivatePersonalDetails = [...KEYS_TO_PRESERVE, ONYXKEYS.PRIVATE_PERSONAL_DETAILS];
1331-
Onyx.clear(keysToPreserveWithPrivatePersonalDetails).then(() => updateAuthTokenAndOpenApp(response.authToken, response.encryptedAuthToken));
1334+
clearOnyxAndSeedFullReconnect(keysToPreserveWithPrivatePersonalDetails).then(() => updateAuthTokenAndOpenApp(response.authToken, response.encryptedAuthToken));
13321335
return;
13331336
}
13341337

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Onyx from 'react-native-onyx';
2+
import type {OnyxKey, OnyxMultiSetInput} from 'react-native-onyx';
3+
import DateUtils from '@libs/DateUtils';
4+
import ONYXKEYS from '@src/ONYXKEYS';
5+
6+
/**
7+
* Atomically clears Onyx while seeding LAST_FULL_RECONNECT_TIME=now so that
8+
* subscribeToFullReconnect doesn't fire a redundant ReconnectApp when the caller
9+
* runs an OpenApp right after the clear: OpenApp's response.onyxData carries
10+
* NVP_RECONNECT_APP_IF_FULL_RECONNECT_BEFORE and applyHTTPSOnyxUpdates applies
11+
* response.onyxData before successData, so without the seed the timestamp would
12+
* still be empty when the comparison runs and trigger a duplicate reconnect.
13+
*
14+
* Pass `extraSeeds` to seed additional keys atomically with the timestamp (e.g.
15+
* IS_LOADING_APP=true for delegate transitions). Seeded keys are appended to the
16+
* preserve list automatically so they survive the clear.
17+
*/
18+
function clearOnyxAndSeedFullReconnect(keysToPreserve: OnyxKey[], extraSeeds?: OnyxMultiSetInput): Promise<void> {
19+
const seeds: OnyxMultiSetInput = {
20+
...extraSeeds,
21+
[ONYXKEYS.LAST_FULL_RECONNECT_TIME]: DateUtils.getDBTime(),
22+
};
23+
return Onyx.multiSet(seeds).then(() => Onyx.clear([...keysToPreserve, ...(Object.keys(seeds) as OnyxKey[])]));
24+
}
25+
26+
export default clearOnyxAndSeedFullReconnect;
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import Onyx from 'react-native-onyx';
2+
import clearOnyxAndSeedFullReconnect from '@libs/actions/clearOnyxAndSeedFullReconnect';
3+
import DateUtils from '@libs/DateUtils';
4+
import CONST from '@src/CONST';
5+
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
6+
import ONYXKEYS from '@src/ONYXKEYS';
7+
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
8+
9+
OnyxUpdateManager();
10+
11+
describe('actions/clearOnyxAndSeedFullReconnect', () => {
12+
beforeAll(() => {
13+
Onyx.init({keys: ONYXKEYS});
14+
});
15+
16+
beforeEach(async () => {
17+
await Onyx.clear();
18+
await waitForBatchedUpdates();
19+
});
20+
21+
it('seeds LAST_FULL_RECONNECT_TIME with the current DB time so subscribeToFullReconnect does not fire a duplicate ReconnectApp before OpenApp successData arrives', async () => {
22+
await Onyx.set(ONYXKEYS.LAST_FULL_RECONNECT_TIME, null);
23+
await waitForBatchedUpdates();
24+
25+
const before = DateUtils.getDBTime();
26+
await clearOnyxAndSeedFullReconnect([]);
27+
await waitForBatchedUpdates();
28+
29+
await new Promise<void>((resolve) => {
30+
const connection = Onyx.connect({
31+
key: ONYXKEYS.LAST_FULL_RECONNECT_TIME,
32+
callback: (value) => {
33+
expect(typeof value).toBe('string');
34+
expect((value ?? '').length > 0).toBe(true);
35+
expect((value ?? '') >= before).toBe(true);
36+
Onyx.disconnect(connection);
37+
resolve();
38+
},
39+
});
40+
});
41+
});
42+
43+
it('preserves keys passed in keysToPreserve and clears everything else', async () => {
44+
await Onyx.multiSet({
45+
[ONYXKEYS.SESSION]: {accountID: 1, authToken: 'preserved-auth-token'},
46+
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
47+
});
48+
await waitForBatchedUpdates();
49+
50+
await clearOnyxAndSeedFullReconnect([ONYXKEYS.SESSION]);
51+
await waitForBatchedUpdates();
52+
53+
await new Promise<void>((resolve) => {
54+
const connection = Onyx.connect({
55+
key: ONYXKEYS.SESSION,
56+
callback: (value) => {
57+
expect(value?.authToken).toBe('preserved-auth-token');
58+
Onyx.disconnect(connection);
59+
resolve();
60+
},
61+
});
62+
});
63+
64+
await new Promise<void>((resolve) => {
65+
const connection = Onyx.connect({
66+
key: ONYXKEYS.NVP_PRIORITY_MODE,
67+
callback: (value) => {
68+
expect(value).toBeUndefined();
69+
Onyx.disconnect(connection);
70+
resolve();
71+
},
72+
});
73+
});
74+
});
75+
76+
it('applies extraSeeds atomically and keeps seeded keys after the clear', async () => {
77+
await Onyx.set(ONYXKEYS.IS_LOADING_APP, false);
78+
await waitForBatchedUpdates();
79+
80+
await clearOnyxAndSeedFullReconnect([], {
81+
[ONYXKEYS.IS_LOADING_APP]: true,
82+
});
83+
await waitForBatchedUpdates();
84+
85+
await new Promise<void>((resolve) => {
86+
const connection = Onyx.connect({
87+
key: ONYXKEYS.IS_LOADING_APP,
88+
callback: (value) => {
89+
expect(value).toBe(true);
90+
Onyx.disconnect(connection);
91+
resolve();
92+
},
93+
});
94+
});
95+
96+
await new Promise<void>((resolve) => {
97+
const connection = Onyx.connect({
98+
key: ONYXKEYS.LAST_FULL_RECONNECT_TIME,
99+
callback: (value) => {
100+
expect(typeof value).toBe('string');
101+
expect((value ?? '').length > 0).toBe(true);
102+
Onyx.disconnect(connection);
103+
resolve();
104+
},
105+
});
106+
});
107+
});
108+
109+
it('does not let extraSeeds override the LAST_FULL_RECONNECT_TIME seed', async () => {
110+
// Caller tries to override the timestamp seed with an empty string — the helper must win.
111+
await clearOnyxAndSeedFullReconnect([], {
112+
[ONYXKEYS.LAST_FULL_RECONNECT_TIME]: '',
113+
});
114+
await waitForBatchedUpdates();
115+
116+
await new Promise<void>((resolve) => {
117+
const connection = Onyx.connect({
118+
key: ONYXKEYS.LAST_FULL_RECONNECT_TIME,
119+
callback: (value) => {
120+
expect(typeof value).toBe('string');
121+
expect((value ?? '').length > 0).toBe(true);
122+
Onyx.disconnect(connection);
123+
resolve();
124+
},
125+
});
126+
});
127+
});
128+
129+
it('clears keys that were not in keysToPreserve and were not seeded', async () => {
130+
await Onyx.multiSet({
131+
[ONYXKEYS.SESSION]: {accountID: 1, authToken: 'preserved-auth-token'},
132+
[ONYXKEYS.IS_LOADING_APP]: false,
133+
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
134+
[ONYXKEYS.HAS_LOADED_APP]: true,
135+
});
136+
await waitForBatchedUpdates();
137+
138+
// Preserve SESSION, seed IS_LOADING_APP, leave the rest to be cleared.
139+
await clearOnyxAndSeedFullReconnect([ONYXKEYS.SESSION], {
140+
[ONYXKEYS.IS_LOADING_APP]: true,
141+
});
142+
await waitForBatchedUpdates();
143+
144+
await new Promise<void>((resolve) => {
145+
const connection = Onyx.connect({
146+
key: ONYXKEYS.NVP_PRIORITY_MODE,
147+
callback: (value) => {
148+
expect(value).toBeUndefined();
149+
Onyx.disconnect(connection);
150+
resolve();
151+
},
152+
});
153+
});
154+
155+
await new Promise<void>((resolve) => {
156+
const connection = Onyx.connect({
157+
key: ONYXKEYS.HAS_LOADED_APP,
158+
callback: (value) => {
159+
expect(value).toBeUndefined();
160+
Onyx.disconnect(connection);
161+
resolve();
162+
},
163+
});
164+
});
165+
166+
await new Promise<void>((resolve) => {
167+
const connection = Onyx.connect({
168+
key: ONYXKEYS.SESSION,
169+
callback: (value) => {
170+
expect(value?.authToken).toBe('preserved-auth-token');
171+
Onyx.disconnect(connection);
172+
resolve();
173+
},
174+
});
175+
});
176+
177+
await new Promise<void>((resolve) => {
178+
const connection = Onyx.connect({
179+
key: ONYXKEYS.IS_LOADING_APP,
180+
callback: (value) => {
181+
expect(value).toBe(true);
182+
Onyx.disconnect(connection);
183+
resolve();
184+
},
185+
});
186+
});
187+
});
188+
});

0 commit comments

Comments
 (0)