-
-
Couldn't load subscription status.
- Fork 677
Use Identity instead of index for ACCOUNT_SWITCH and ACCOUNT_REMOVE
#5634
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
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for spotting this issue and the fix!
I've read through the first two commits:
469eb65 accounts reducer tests: Make ACCOUNT_REMOVE test a bit stronger
bc8d444 accountsReducer: Invariant that ACCOUNT_REMOVE's index is in bounds
and partway through the next:
f196eb4 account actions: Have removeAccount take an Identity, not an index
Just one comment so far, below. I'll return to read the remainder of the branch when I'm back at work next week.
src/account/accountsSelectors.js
Outdated
| * | ||
| * Gives false if there is no active account. | ||
| */ | ||
| export const getIsActiveAccount: GlobalSelector<(Identity) => boolean> = createSelector( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't benefit from memoization (i.e. from createSelector) — the callback we pass to createSelector is not doing any work, just immediately returning a function expression.
Removing that will simplify things. Then I think let's simplify further by uncurrying, so that this is just a function on two arguments rather than a function on one argument that returns a function on the other argument.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. I think I was looking at this other selector when writing this one:
/**
* All known accounts, indexed by identity.
*/
export const getAccountsByIdentity: GlobalSelector<(Identity) => Account | void> = createSelector(
getAccounts,
accounts => {
const map = new Map(
accounts.map(account => [keyOfIdentity(identityOfAccount(account)), account]),
);
return identity => map.get(keyOfIdentity(identity));
},
);That one is "curried" too, right? I wonder if there's a way to simplify that one, while still enforcing that callers pass the right kind of thing to identify the account (whether an Identity or a keyOfIdentity string). Anyway, not worth spending too much time on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That one has the type of a curried function, in that it's of the form A => (B => C) (whereas the uncurried counterpart of that type would be (A, B) => C.
But the implementation has more going on. An implementation that's purely curried without more happening would look like:
export const getAccountsByIdentity: GlobalState => ((Identity) => Account | void) = state => identity => {
const accounts = getAccounts(state);
const map = new Map(
accounts.map(account => [keyOfIdentity(identityOfAccount(account)), account]),
);
return map.get(keyOfIdentity(identity));
};The actual implementation using createSelector, though, does a cache lookup before the new Map step. Because the new Map represents doing some work (constructing a new data structure, vs. just looking up properties in existing objects and doing simple boolean tests), that caching is potentially useful.
2e5f098 to
444f427
Compare
|
Thanks for that review comment! Revision pushed, addressing it by rewriting |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision!
Finished reading the branch. Generally all looks good — I appreciate the careful reasoning in the commits that add invariants. One comment below, on the NFC last commit.
src/account/AccountItem.js
Outdated
| const { email, realm, isLoggedIn } = props.account; | ||
| const { identity } = props; | ||
| const { email, realm } = identity; | ||
|
|
||
| const accountStatus = useGlobalSelector(state => getAccountStatusByIdentity(state)(identity)); | ||
|
|
||
| const isActiveAccount = useGlobalSelector(state => getIsActiveAccount(state, identity)); | ||
| const isLoggedIn = accountStatus.isLoggedIn; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the ideal version of this component wouldn't use any Redux selectors, so that it's just a plain piece of UI that takes the data it's given via props and presents a UI for it.
That way an ancestor component, here AccountPickScreen, is responsible for putting together the needed data — basically a view-model — and passing it down. This is the arrangement the existing code in main has.
It'd be a little bit annoying to refactor things so that the new version of this component can be pure in that sense. Really what's happening is that the job of getAccountStatuses is to prepare the view-model for this screen, and so we'd want to make a renamed version of that selector that's more explicitly aimed at that, and then cheerfully add to it whatever other data this screen comes to need, like identifying which account is active. (This is a bit annoying because getAccountStatuses has grown one other call site. That call site doesn't actually use isLoggedIn, though; so it should just call getIdentities instead.)
At a minimum, though, we can refrain from refactoring to give this new dependencies on Redux for data it already had. I.e., we can let this component keep taking an AccountStatus (and AccountList keep taking an array of those), even if we add a useGlobalSelector call to get isActiveAccount.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sent a quick PR #5649 to do part of this refactoring, reframing getAccountStatuses as that view-model.
By having it expect that *only* the specified account is removed, and other accounts remain. And format the code a bit more tightly.
If `action.index` is negative, the behavior of `.splice` seems (from an experiment) to be to count from the end of the array, so `.splice(-1, 1)` would cut the last element off. This would cause confusion if it ever happened, i.e., if the one ACCOUNT_REMOVE dispatcher, in AccountPickScreen, passed a negative index. Fortunately, it doesn't. The index comes from React Native's `FlatList`: it's the index for one of the account items to render. I don't think React Native would ever make that negative. If `action.index` is too high for the array, then (experimentally) `.splice` would leave it alone, which is better than corrupting the data. In theory, this could happen: somehow `state.accounts` changes after the "Remove account" confirmation dialog appears, such that when the user presses "Confirm", the index is out of bounds. We don't want this to happen (hence the invariant), but, experimentally, `.splice()` would just leave the accounts state unchanged, which is better than changing it in a weird way.
This encapsulates away from dispatchers the detail that `state.accounts` is implemented as an array. Now, callers have to be more expressive about what account they want to remove: not "the one at index 2", but "the one with this realm and this email". That should fix the following bug, which is admittedly hard to trigger but is still a bug. Here's what I did on Android at v27.196: - Tap an account's trashcan icon - Very quickly, before the confirmation dialog can show up and prevent you from doing so, tap elsewhere on the card for the account, so the app switches to that account - Press "Confirm" on the "Remove account" dialog - See that an account was removed, but it wasn't the one mentioned in the dialog
If this invariant isn't met, we'll end up with an AccountsState that has `undefined` for its first item, and that would crash things up pretty quickly. Fortunately, half of the invariant already holds: `action.index` won't be negative. Here's how we know that for both of accountSwitch's two callsites: - In the callsite in AccountPickScreen, the index comes from React Native's `FlatList`: it's meant to be the index for one of the account items to render. I don't think React Native would ever make that negative. - In the callsite in narrowToNotification, it's the index passed to an array's `forEach` callback (the `state.accounts` array), so that can't be negative. Of course, here in the reducer, we're using `action.index` as an index into the `state.accounts` array as it stands at dispatch time. If dispatchers are doing the same thing, there shouldn't be a problem with the other half of the invariant: that `action.index` isn't too high. There seems to be just one place we fall short there: unfortunately, the caller in AccountPickScreen was yielding between the time it read `state.accounts` and dispatched the action, with an unexplained setTimeout. …So, remove that setTimeout, since in theory `state.accounts` could change during those few milliseconds, such that `action.index` is too high. Since in this case the reducer would add an `undefined` to the end of `state.accounts`, add a migration. I tested the AccountPickScreen manually after this change, and switching accounts worked fine.
444f427 to
42bebed
Compare
|
Thanks for the review! Revision pushed. |
|
Thanks for the revision! Looks good; merging. |
This turned out to be independent of #5633.