Fix issue #15321: The app crashes when the user is logged into multiple tabs and logs out of one of the tabs#237
Conversation
|
CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
|
|
||
| // Call clear() and make sure that the default key/values and the key/values from the parameter | ||
| // are preserved in storage. This makes sure to always leave storage in a state that contains | ||
| // all the default values and any additional values that we want to remain after the database is cleared. |
There was a problem hiding this comment.
Let's keep a comment here to explain what we're doing and why
There was a problem hiding this comment.
@madmax330 Thanks for pointing that out. I have updated, please help to check again
There was a problem hiding this comment.
updated it to only say what and not why...
roryabraham
left a comment
There was a problem hiding this comment.
I think this function could use some additional cleanup. I found it pretty hard to follow, and for the most part we just need one main loop.
function clear(keysToPreserve = []) {
return getAllKeys()
.then((keys) => {
const keysToBeClearedFromStorage = [];
const keyValuesToResetAsCollection = {};
const keyValuesToResetIndividually = {};
// The only keys that should not be cleared are:
// 1. Anything specifically passed in keysToPreserve (because some keys like language preferences, offline
// status, or activeClients need to remain in Onyx even when signed out)
// 2. Any keys with a default state (because they need to remain in Onyx as their default, and setting them
// to null would cause unknown behavior)
_.each(keys, (key) => {
const isKeyToPreserve = _.contains(keysToPreserve, key);
const isDefaultKey = _.has(defaultKeyStates, key);
// If the key is being removed or reset to default:
// 1. Update it in the cache
// 2. Figure out whether it is a collection key or not,
// since collection key subscribers need to be updated differently
if (!isKeyToPreserve) {
const newValue = defaultKeyStates[key] || null;
cache.set(key, newValue);
const collectionKey = key.substring(0, key.indexOf('_') + 1);
if (collectionKey) {
if (!keyValuesToResetAsCollection[collectionKey]) {
keyValuesToResetAsCollection[collectionKey] = {};
}
keyValuesToResetAsCollection[collectionKey][key] = newValue;
} else {
keyValuesToResetIndividually[key] = newValue;
}
}
if (isKeyToPreserve || isDefaultKey) {
return;
}
// If it isn't preserved and doesn't have a default, we'll remove it
keysToBeClearedFromStorage.push(key);
});
// Notify the subscribers for each key/value group so they can receive the new values
_.each(keyValuesToResetIndividually, (value, key) => {
notifySubscribersOnNextTick(key, value);
});
_.each(keyValuesToResetAsCollection, (value, key) => {
notifyCollectionSubscribersOnNextTick(key, value);
});
// Remove only the items that we want cleared from storage
return Storage.removeItems(keysToBeClearedFromStorage);
});
sobitneupane
left a comment
There was a problem hiding this comment.
@tienifr I am not getting the excepted results. Can you please rerun the tests with latest changes?
Screen.Recording.2023-03-01.at.18.47.46.mov
|
@roryabraham Thanks for your comment, but the PR isn't completed, I still need to change a few things to make it work |
|
@roryabraham @sobitneupane I just fixed all comments, also tested again and it works correctly. Please help to check again. Thanks Screen.Recording.2023-03-02.at.00.25.02.mov |
|
@roryabraham I agree with you that we should only mutilSet the default keys. I just fixed that, please check again. |
sobitneupane
left a comment
There was a problem hiding this comment.
I am getting following console errors.

Why does the other tab updates only when the tab is active? You can notice in the video that tab updates only when the user switches to the tab. It indicates that the onyx is synced only when the tab is active which can create some serious issues.
Screen.Recording.2023-03-02.at.18.55.53.mov
lib/Onyx.js
Outdated
| // all the default values and any additional values that we want to remain after the database is cleared. | ||
| return Storage.clear() | ||
| .then(() => Storage.multiSet([...defaultKeyValuePairs, ...keyValuesToPreserve])); | ||
| const defaultKeyValuePairs = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve)); |
@sobitneupane Did you remember to run |
package.json
Outdated
| "dependencies": { | ||
| "ascii-table": "0.0.9", | ||
| "fast-equals": "^4.0.3", | ||
| "localforage-removeitems": "^1.4.0", |
There was a problem hiding this comment.
I think this should be a peerDependency instead of a dependency
|
@roryabraham I've fixed your comments, pls help review again |
@sobitneupane I think this is not related to the scope of this PR (or this issue), since even if we apply only this patch on |
roryabraham
left a comment
There was a problem hiding this comment.
This PR is looking good to me 👍🏼
| * @param {Array} keys | ||
| * @returns {Promise} | ||
| */ | ||
| removeItems: keys => Promise.all(_.map(keys, key => AsyncStorage.removeItem(key))), |
There was a problem hiding this comment.
This is required because we're using AsyncStorage as the mock provider for NativeStorage in unit tests, so removeItems needs to be defined here as well
cc @roryabraham @sobitneupane
|
@roryabraham, I've added |
I don't agree with you @tienifr. I tested the PR. This is the result in the PR. Screen.Recording.2023-03-09.at.22.41.30.movAnd following is the result with the changes in this PR. Screen.Recording.2023-03-09.at.22.59.21.movPlease notice the unread indicators in both the videos. With the change in this PR, unread indicator only goes away after user switches to the tab indicating onyx is cleared only after user switches to the tab. |
|
@sobitneupane Thanks for pointing that out. I fixed this issue and here is the result. Please help check again. Screen.Recording.2023-03-10.at.17.54.56.mp4 |
|
@tienifr Many changes are made since PR is created. So, can you please update the screen recordings in PR body. Also, please do include screen recordings for heavy traffic accounts. |
Thanks for the heads up! I have updated the screen recordings with HTA. I also made some small changes so the code could pass the lint test. Please have a look. |
lib/Onyx.js
Outdated
| const keysToBeClearedFromStorage = []; | ||
| const keyValuesToResetAsCollection = {}; | ||
| const keyValuesToResetIndividually = {}; | ||
| const defaultKeys = _.keys(_.omit(defaultKeyStates, ...keysToPreserve)); |
There was a problem hiding this comment.
Why do we need the keysToReset here? It seems to me like this logic should be covered in the main loop right?
There was a problem hiding this comment.
Also, if this is needed for some reason I'm not seeing, there's an unnecessary spread:
| const defaultKeys = _.keys(_.omit(defaultKeyStates, ...keysToPreserve)); | |
| const defaultKeys = _.keys(_.omit(defaultKeyStates, keysToPreserve)); |
There was a problem hiding this comment.
@roryabraham Thanks for your comment. I think we should notify subscribers when data has been cleared as I can see this test on onyxTest.js. After checking the old logic I see we push the defaultKeyValuePairs to keyValuesToReset, that why I decide to have keysToReset. You can check the test
react-native-onyx/tests/unit/onyxTest.js
Line 95 in 4887af4
the keys = [ 'test' ] and defaultKeyStates = { otherTest: 42 }, if we don't push
otherTest to keys, we'll miss this value.
And yes, you're right, having spread is unnecessary. I updated my PR to remove the spread.
Lines 1070 to 1072 in 4887af4
There was a problem hiding this comment.
Thanks for explaining. I've spent some time testing, and I still don't think this code is needed. I modified the onyxTest a bit to test that default keys are reset:
diff --git a/tests/unit/onyxTest.js b/tests/unit/onyxTest.js
index 50b8608..e9a955e 100644
--- a/tests/unit/onyxTest.js
+++ b/tests/unit/onyxTest.js
@@ -112,9 +112,10 @@ describe('Onyx', () => {
return waitForPromisesToResolve()
.then(() => Onyx.set(ONYX_KEYS.TEST_KEY, 'test'))
+ .then(() => Onyx.set(ONYX_KEYS.OTHER_TEST, 420))
.then(() => {
expect(testKeyValue).toBe('test');
- expect(otherTestValue).toBe(42);
+ expect(otherTestValue).toBe(420);
return Onyx.clear().then(waitForPromisesToResolve);
})
.then(() => {Then I reset the code to how it was before:
diff --git a/lib/Onyx.js b/lib/Onyx.js
index 910b759..2667754 100644
--- a/lib/Onyx.js
+++ b/lib/Onyx.js
@@ -1050,16 +1050,13 @@ function clear(keysToPreserve = []) {
const keysToBeClearedFromStorage = [];
const keyValuesToResetAsCollection = {};
const keyValuesToResetIndividually = {};
- const defaultKeys = _.keys(_.omit(defaultKeyStates, keysToPreserve));
- const keysToReset = _.difference(keys, defaultKeys);
- keysToReset.push(...defaultKeys);
// The only keys that should not be cleared are:
// 1. Anything specifically passed in keysToPreserve (because some keys like language preferences, offline
// status, or activeClients need to remain in Onyx even when signed out)
// 2. Any keys with a default state (because they need to remain in Onyx as their default, and setting them
// to null would cause unknown behavior)
- _.each(keysToReset, (key) => {
+ _.each(keys, (key) => {
const isKeyToPreserve = _.contains(keysToPreserve, key);
const isDefaultKey = _.has(defaultKeyStates, key);
And everything worked as expected. Since this is simpler, let's change it back.
There was a problem hiding this comment.
Screenshots/Videos
Web
Screen.Recording.2023-03-13.at.18.25.05.mov
Mobile Web - Chrome
Screen.Recording.2023-03-13.at.18.29.38.mov
Mobile Web - Safari
Screen.Recording.2023-03-13.at.18.29.38.mov
Desktop
Screen.Recording.2023-03-14.at.16.25.04.mov
iOS
Screen.Recording.2023-03-13.at.18.32.15.mov
Android
Screen.Recording.2023-03-13.at.18.33.43.mov
roryabraham
left a comment
There was a problem hiding this comment.
Also, not going to require this since it's probably fine if we don't, but I think it might be good to prevent updating subscribers unless the data actually changes:
@@ -1068,16 +1065,19 @@ function clear(keysToPreserve = []) {
// 2. Figure out whether it is a collection key or not,
// since collection key subscribers need to be updated differently
if (!isKeyToPreserve) {
+ const oldValue = cache.getValue(key);
const newValue = _.get(defaultKeyStates, key, null);
- cache.set(key, newValue);
- const collectionKey = key.substring(0, key.indexOf('_') + 1);
- if (collectionKey) {
- if (!keyValuesToResetAsCollection[collectionKey]) {
- keyValuesToResetAsCollection[collectionKey] = {};
+ if (newValue !== oldValue) {
+ cache.set(key, newValue);
+ const collectionKey = key.substring(0, key.indexOf('_') + 1);
+ if (collectionKey) {
+ if (!keyValuesToResetAsCollection[collectionKey]) {
+ keyValuesToResetAsCollection[collectionKey] = {};
+ }
+ keyValuesToResetAsCollection[collectionKey][key] = newValue;
+ } else {
+ keyValuesToResetIndividually[key] = newValue;
}
- keyValuesToResetAsCollection[collectionKey][key] = newValue;
- } else {
- keyValuesToResetIndividually[key] = newValue;
}
}
|
@roryabraham Yes, you're right. So I updated my PR as your suggestion, I also removed unnecessary test case. Please help review again. Thanks. @sobitneupane Here is my result on desktop Screen.Recording.2023-03-14.at.00.57.34.mov |
|
@roryabraham I think we are good to merge this PR. |
|
Are we good to go for this PR to be merged? |
|
@roryabraham Could you check again and merged it if there are no more problems? |
|
Shoot, was just about to merge this, but I can't because it contains unsigned commits. @tienifr Please either retroactively sign all these commits then force-push to this branch, or create a new PR with 100% signed commits and tag us again. |
|
@roryabraham My bad, I just squashed commit and forced push. Please check again and merge this PR |
| const newLocalforage = localforage; | ||
| newLocalforage.removeItems = keys => new Promise((resolve) => { | ||
| _.each(keys, (key) => { | ||
| delete newLocalforage.storageMap[key]; |
There was a problem hiding this comment.
I'm confused by this. Does this actually delete the stuff from IndexedDB? It looks like it would not...
There was a problem hiding this comment.
Well this is a mock. This code has changed a bit since this PR was created, but the mock isn't actually writing anything to IndexedDB. storageMapInternal is just an object so I think delete does work here?


Details
This PR changes the logic of the clear() function which cause issue Expensify/App#15321 so that instead of removing all the keys then adding the default value for some important keys back, it only removes the keys that are not needed and retains the important keys.
It also address the performance concern of Expensify/App#15321 (comment) since the key removals are execute asynchronously. The total execution time has almost no difference compared to the old implementation.
Fixed Issues
$ Expensify/App#15321
$ Expensify/App#15321 (comment)
Tests
QA Steps
Screenshots
Web
new_web.mp4
crash_web_safari.mov
Mobile Web - Chrome
new_mweb_chrome.mp4
Mobile Web - Safari
crash_mweb_safari.mov
Desktop
crash_desktop.mov
iOS
crash_ios.mov
Android
new_android.mp4