Skip to content

Conversation

@chrisbobbe
Copy link
Contributor

@chrisbobbe chrisbobbe commented Jan 3, 2023

This turned out to be independent of #5633.

@chrisbobbe chrisbobbe requested a review from gnprice January 3, 2023 20:12
Copy link
Member

@gnprice gnprice left a 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.

*
* Gives false if there is no active account.
*/
export const getIsActiveAccount: GlobalSelector<(Identity) => boolean> = createSelector(
Copy link
Member

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.

Copy link
Contributor Author

@chrisbobbe chrisbobbe Jan 13, 2023

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.

Copy link
Member

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.

@chrisbobbe chrisbobbe force-pushed the pr-account-item-use-identity branch from 2e5f098 to 444f427 Compare January 13, 2023 00:12
@chrisbobbe
Copy link
Contributor Author

Thanks for that review comment! Revision pushed, addressing it by rewriting getIsActiveAccount as you described.

Copy link
Member

@gnprice gnprice left a 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.

Comment on lines 54 to 62
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;
Copy link
Member

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.

Copy link
Member

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.
@chrisbobbe chrisbobbe force-pushed the pr-account-item-use-identity branch from 444f427 to 42bebed Compare January 26, 2023 18:29
@chrisbobbe
Copy link
Contributor Author

Thanks for the review! Revision pushed.

@gnprice
Copy link
Member

gnprice commented Jan 26, 2023

Thanks for the revision! Looks good; merging.

@gnprice gnprice merged commit 42bebed into zulip:main Jan 26, 2023
@chrisbobbe chrisbobbe deleted the pr-account-item-use-identity branch January 26, 2023 21:08
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.

2 participants