Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable active client manager, multiple tabs, and previous PR cleanup #469

Merged
merged 22 commits into from
Sep 23, 2020

Conversation

tgolen
Copy link
Contributor

@tgolen tgolen commented Sep 11, 2020

Tests for multi-tab functionality

  • Open multiple tabs to the chat application with the same USER A signed in
  • Inspect localStorage to see that activeClients has multiple GUIDs
  • In a different browser (or in an incognito window), sign in to the chat application as USER B (make sure both users have access to the same reports)
  • As USER B, send a comment to any chat that USER A is not currently looking at
  • Verify there is a browser notification and clicking it should take you to one of the chat apps for USER A (the first tab you opened)
  1. Go to the second tab for USER A and check the JS console (you need to have verbose logging turned on) and verify you see: [NOTIFICATION] Skipping notification because this client is not the leader
  2. Now close the first tab for USER A
  3. Go back to USER B and add a new comment
  4. Verify there is a browser notification and clicking it should take you to the tab for USER A that is still open

@tgolen tgolen self-assigned this Sep 11, 2020
cead22
cead22 previously approved these changes Sep 11, 2020
src/lib/Ion.js Outdated
@@ -1,39 +1,13 @@
import _ from 'underscore';
import AsyncStorage from '@react-native-community/async-storage';
import StorageEvent from './StorageEvent';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB but importing this as addStorageEventListener would make init read like this and imo be clearer

function init() {
    addStorageEventListener((key, newValue) => keyChanged(key, newValue));
}

src/lib/Ion.js Outdated
// Values that are objects can be merged into storage
if (_.isObject(val)) {
// Values that are objects or arrays are merged into storage
if (_.isObject(val) || _.isArray(val)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second condition is redundant, but the comment is good

> _.isObject([])
> true

Returns true if value is an Object. Note that JavaScript arrays and functions are objects, while (normal) strings and numbers are not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you know, I had read that... but I ran into issues with that and I needed to add this code. Let me try again.

@tgolen
Copy link
Contributor Author

tgolen commented Sep 14, 2020

Updated with both suggestions 👍

Copy link
Contributor

@marcaaron marcaaron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Looks great to me. Just have a few NAB/questions.

src/Expensify.js Outdated
</Router>

// </Beforeunload>
<Beforeunload onBeforeunload={ActiveClientManager.removeClient}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weird that this is not onBeforeUnload is it something we control?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's straight from the lib. Now that I think about it though, we could definitely just write this ourselves and not need a third-party lib to do it.

src/Expensify.js Outdated
<Route path={['/home', '/']} component={HomePage} />
</Switch>
</Router>
</Beforeunload>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious why we are using this as a wrapper and not render with a self-closing tag? Is there a reason?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No reason. In fact, if we're writing our own version, I'd rather just have it be a utility method as opposed to some component that's rendered.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep you are reading my mind 😅

const init = () => Ion.set(IONKEYS.ACTIVE_CLIENTS, {[clientID]: clientID});
function init() {
Ion.merge(IONKEYS.ACTIVE_CLIENTS, {[clientID]: clientID});
}

/**
* Remove this client ID from the array of active client IDs when this client is exited
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB, maybe use map instead of array here just to avoid confusion as the activeClients is an Object and not Array

// At the moment activeClients only has 1 value i.e., the latest clientID so let's compare if
// the latest matches the current browsers clientID.
return activeClients[clientID] === clientID;
return _.first(_.keys(activeClients)) === clientID;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason we are using an object and not an array? I know if we are adding a new string key to an object that the order will be preserved. But is the same true for Ion.merge()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using an array, and I ran into a lot of problems with it being converted into a strange object instead like {0: '1234', 1: '3456'}. I can try digging into this more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's interesting. Is this just a limitation of AsyncStorage when we try to save an array and get it again? Or does this happen when we use AsyncStorage.mergeItem()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I boiled this down to an implementation "detail" (maybe bug) of async storage:

AsyncStorage.mergeItem('timtest', JSON.stringify([1]));

image

So... merging doesn't really work with arrays. I am going to add a solution where we manually merge arrays inside of Ion.merge() to have the expected behavior.

@tgolen
Copy link
Contributor Author

tgolen commented Sep 14, 2020

Updated with simplifications!

One thing that I have seen happen is that it's fairly easy for an active client GUID to get stuck at the front of the array. I've seen this happen multiple times in local development, but I don't know if it was just because of errors in the code, or actual behavior. @marcaaron I've changed the name of the storage key to help with this (and not conflict with what was previously stored), but please keep an eye out for this. If it happens in production, it's bad because it would mean that web would never have notifications happening. I don't know if there is some sort of "fail-safe" that we can add to this to ensure that this situation would be minimized. Maybe like the GUID expiring after a certain amount of time? I don't know...

*/
function addStorageEventHandler(callback) {
window.addEventListener('storage', (e) => {
callback(e.key, JSON.parse(e.newValue));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering if we should add any sort of check to see if the localStorage changes are actually related to our app as other things (chrome extensions etc) might possibly be sharing localStorage? Maybe it doesn't matter since we'll call keyChanged() and won't have any subscribers? But it would be better to not call that at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not worry about that unless it becomes an issue 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok that works for me. It's easy enough to add in later if it becomes a problem since we should theoretically list all keys in IONKEYS

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also wait to see if this is a problem, but I was thinking of wrapping this in try/catch in case newValue isn't valid JSON, but I don't know how that would happen

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, adding a try/catch for the json.parse() is a good idea. Let me add that.

@tgolen
Copy link
Contributor Author

tgolen commented Sep 16, 2020

Would it be possible to get this merged soon?

Copy link
Contributor

@marcaaron marcaaron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM just left one more comment about Ion.merge with arrays and there are conflicts.

@@ -2,7 +2,7 @@
* This is a file containing constants for all the top level keys in our store
*/
export default {
ACTIVE_CLIENTS: 'activeClients',
ACTIVE_CLIENTS: 'activeClients2',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it was intentional... and I'm open to a better solution. The problem is that these were stored as an object before, and sense we are only calling Ion.merge() for this, moving from an object to an array is not possible and will result in really bad values being stored.

One possible solution is to rename it to maybe activeClientIDs, then add some code which clears out activeClients, and then after a week, we can go back and set it back to activeClients. Whatever solution we think of here, we should try to find some kind of method that is fairly reproducible because I think we will need to do this occassionally.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok changing the name works for now!

I think learn term we need a way to perform localStorage migrations for when keys change by adding some kind of instructions before the app updates or inits. That might mean we have some migration code running that based on the version will alter the localStorage schema or simply blow it away and start over from scratch minus session information.

Seems like a similar problem people run into when implementing a ServiceWorker

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this. The way I imagine this would work is something like

  • If the new version of the app renames a key or wants to clear old keys
  • When the app inits
  • Clear local storage (like signout does)
  • Re-authenticate
  • Load everything from scratch

That should all be transparent to the user

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think the catch there is that process wouldn't be exactly "transparent" to the user, particularly the "load everything from scratch" part. I'm going to spin up an issue so we can discuss this further and plan on leaving this code the way it is for now.

src/lib/Ion.js Outdated
console.warn(`It looks like a React component subscribed to multiple Ion keys without
providing an 'indexBy' option. This will result in undefined behavior. The best thing to do is
provide an 'indexBy' value, or use a more specific regex that will only match a single Ion key.`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can maybe be removed after merging master?

src/lib/Ion.js Show resolved Hide resolved
*/
function addStorageEventHandler(callback) {
window.addEventListener('storage', (e) => {
callback(e.key, JSON.parse(e.newValue));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok that works for me. It's easy enough to add in later if it becomes a problem since we should theoretically list all keys in IONKEYS

@tgolen
Copy link
Contributor Author

tgolen commented Sep 16, 2020

Updated, thanks!

@marcaaron marcaaron self-requested a review September 16, 2020 22:09
Copy link
Contributor

@marcaaron marcaaron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm wait something is not right actually. When I start up the app with localStorage completely cleared somehow I get this...

Screenshot 1348

@marcaaron
Copy link
Contributor

Oh that's interesting so... localStorage.clear() or clearing storage from Chrome dev tools does not actually clear AsyncStorage...

* @returns {boolean}
*/
function isClientTheLeader() {
return _.first(activeClients) === clientID;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checking something here. Wouldn't it be safer to give priority to the last client that was added instead of the first?

e.g. if a beforeunload event doesn't happen then I think we'll might get a zombie "leader" that disappeared a long time ago. Which would leave us in a bad state since we are pushing new clients onto the end of the array. But this wouldn't happen if new clients were pushed onto the front of the array or we treated the last item as the "leader".

@tgolen
Copy link
Contributor Author

tgolen commented Sep 16, 2020

Yeah, so I think you're seeing what I mentioned earlier about GUIDs getting stuck in that array.

clearing storage from Chrome dev tools does not actually clear AsyncStorage...

I am 99% sure that clearing local storage directly does clear AsyncStorage (which is just using local storage underneath). I think something else is happening here. First thing I always check is that there are no other windows open (because those will for sure be adding GUIDs into the array). I think I like the suggestion about using the last GUID instead of the first GUID! That makes sense and should solve that problem hopefully.

@marcaaron
Copy link
Contributor

Yeah thinking about it some more... we don't really even need a beforeunload event. We just need a cap on how many open tabs a person might have and then set a hard limit on how many we'd support. Storing something like 20 IDs in an array probably would work.

@tgolen
Copy link
Contributor Author

tgolen commented Sep 17, 2020

Hm, so when we reach the max, then we'd just start removing the oldest GUID? That could work

@tgolen
Copy link
Contributor Author

tgolen commented Sep 17, 2020

Yep, that works! Updated.

@tgolen
Copy link
Contributor Author

tgolen commented Sep 21, 2020

Bump for review please :)

marcaaron
marcaaron previously approved these changes Sep 21, 2020
@marcaaron
Copy link
Contributor

@AndrewGable or @cead22 want to take a look?

@cead22
Copy link
Contributor

cead22 commented Sep 21, 2020

Conflicts

@cead22
Copy link
Contributor

cead22 commented Sep 22, 2020

Sorry, I didn't get a chance to review today, but I'll give this a look tomorrow

@tgolen
Copy link
Contributor Author

tgolen commented Sep 22, 2020

Conflicts resolved.

cead22
cead22 previously approved these changes Sep 22, 2020
*/
function addStorageEventHandler(callback) {
window.addEventListener('storage', (e) => {
callback(e.key, JSON.parse(e.newValue));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also wait to see if this is a problem, but I was thinking of wrapping this in try/catch in case newValue isn't valid JSON, but I don't know how that would happen

@@ -2,7 +2,7 @@
* This is a file containing constants for all the top level keys in our store
*/
export default {
ACTIVE_CLIENTS: 'activeClients',
ACTIVE_CLIENTS: 'activeClients2',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this. The way I imagine this would work is something like

  • If the new version of the app renames a key or wants to clear old keys
  • When the app inits
  • Clear local storage (like signout does)
  • Re-authenticate
  • Load everything from scratch

That should all be transparent to the user


callback: (val) => {
activeClients = val;
if (activeClients.length >= maxClients) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need to update active clients in Ion in the else case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why we would, no. Can you explain why you're thinking it would be necessary? If you added a set() in the else, then the value of active clients in Ion wouldn't be changing at all.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I fully understood how this works yesterday, but I think I get it now. When init is called, we just add clients. If somebody calls init and we reach the max of clients, we remove the first one that was added 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! Exactly.

@tgolen
Copy link
Contributor Author

tgolen commented Sep 23, 2020

Updated with a try/catch for the JSON parsing

} catch (err) {
console.error('Could not parse the newValue of the storage event', err, e);
}
callback(e.key, JSON.parse(newValue));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be callback(e.key, newValue);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, haha 🤦

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and tested)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants