Skip to content

Store state.messages as an Immutable.Map.#4390

Merged
gnprice merged 17 commits intozulip:masterfrom
chrisbobbe:pr-immutable-messages
Jan 14, 2021
Merged

Store state.messages as an Immutable.Map.#4390
gnprice merged 17 commits intozulip:masterfrom
chrisbobbe:pr-immutable-messages

Conversation

@chrisbobbe
Copy link
Copy Markdown
Contributor

@chrisbobbe chrisbobbe commented Jan 8, 2021

After #4201, we're now using Immutable.Map for state.narrows. This continues that work by making state.messages use Immutable.Map, too; it's one of the priority areas Greg has described.

I haven't yet done any performance measurements; I'd be happy to take that up soon, unless we can already be confident enough that this won't cause any significant performance regressions.

One thing I'm concerned about (as mentioned in the main commit) is that, with the current types, Flow won't catch it if you accidentally pass Immutable.Map({ 1: 'foo' }) where a numeric-keyed Immutable.Map is expected. (That value will have string keys because { 1: 'foo' } is really { '1': 'foo' }.) See where we handle MESSAGE_FETCH_COMPLETE in messagesReducer for one example of getting this right, but where we wouldn't be alerted if we got it wrong.

Copy link
Copy Markdown
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 @chrisbobbe ! This looks great -- two comments below, both on followups.

When I rebase I get a few Flow errors, so I'll hold off merging; please merge at will after resolving those.

Comment thread src/message/messagesReducer.js Outdated
Comment on lines +82 to +85
if (action.messageIds.every(messageId => !state.get(messageId))) {
return state;
}
return omit(state, action.messageIds);
return state.deleteAll(action.messageIds);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can be simplified to just the deleteAll call. (I'm maybe 95% confident that Immutable will indeed return the identical value if none of the keys are present -- but it'd be nice to make sure there's a messagesReducer test confirming that.) Simpler and a bit more efficient, too.

Best as a separate followup commit, as it's nice that this commit is such a straightforward translation.

Comment thread src/message/messagesReducer.js Outdated
Comment on lines +23 to +36
const eventReactionAdd = (state, action) => {
const oldMessage = state[action.message_id];
const oldMessage = state.get(action.message_id);
if (!oldMessage) {
return state;
}
return {
...state,
[action.message_id]: {
...oldMessage,
reactions: oldMessage.reactions.concat({
emoji_name: action.emoji_name,
user_id: action.user_id,
reaction_type: action.reaction_type,
emoji_code: action.emoji_code,
}),
},
};
return state.set(action.message_id, {
...oldMessage,
reactions: oldMessage.reactions.concat({
emoji_name: action.emoji_name,
user_id: action.user_id,
reaction_type: action.reaction_type,
emoji_code: action.emoji_code,
}),
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This pattern can be condensed like so (in a followup), using Immutable.map#update:

const eventReactionAdd = (state, action) =>
  state.update(
    action.message_id,
    oldMessage =>
      oldMessage && {
        ...oldMessage,
        reactions: oldMessage.reactions.concat({
          emoji_name: action.emoji_name,
          user_id: action.user_id,
          reaction_type: action.reaction_type,
          emoji_code: action.emoji_code,
        }),
      },
  );

A crucial feature here of update is that

if the updater function returns the same value it was called with, then no change will occur.

So if the message isn't present, the updater will be passed undefined, and just has to return undefined and the map will be returned unchanged.

I'm kind of ambivalent on this clarity-wise. It's a bit shorter, but does rely on a somewhat subtle fact about update's interface. But two things push me toward thinking that overall it's an improvement:

  • It should be more efficient: the old code requires the key to be looked up in the data structure twice, and with this version Immutable can (and I expect it does) look it up just once.
  • Because it no longer needs a local variable, it can be inlined conveniently into the switch in the reducer. With similar simplifications, all of these helpers can be inlined conveniently into the reducer. That's the style of most of our reducers, and I generally prefer it because it means one fewer hop to look up what the effect of an action is (or vice versa, to find in what situation some code found in one of these is called.)

Those and:

  • If we start making use of this feature routinely, as we well might, then it becomes a familiar one 🙂 and not a puzzling subtlety.

chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Jan 13, 2021
Greg points out [1],

"""
A crucial feature here of `update` is that

> if the `updater` function returns the same value it was called
> with, then no change will occur.

So if the message isn't present, the updater will be passed
`undefined`, and just has to return `undefined` and the map will be
returned unchanged.
"""

See also, in that same comment, how Greg considers the effect on
clarity of relying on that subtle fact, and how that weighs with
other concerns, like efficiency.

[1] zulip#4390 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Jan 13, 2021
As Greg points out [1], this means "one fewer hop to look up what
the effect of an action is (or vice versa, to find in what situation
some code found in one of these is called.)".

[1] zulip#4390 (comment)
@chrisbobbe chrisbobbe force-pushed the pr-immutable-messages branch from 101477a to 04a6858 Compare January 13, 2021 03:41
@chrisbobbe
Copy link
Copy Markdown
Contributor Author

Thanks for the review! Revision pushed. The Flow errors, it turns out, were because I hadn't added ownUserId to some messageFetchComplete actions in the messagesReducer tests (that bit of data was a recent addition).

@chrisbobbe
Copy link
Copy Markdown
Contributor Author

You can see a draft of the followup I mention in the tip commit here.

This test tests against input that wouldn't make it through
type-checking.
In an upcoming commit, we'll start using the recently introduced
`eg.makeMessagesState`, which will simplify the code in this file.
It'll also have the nice bonus of reducing the diff for the commit
where we switch over to using `Immutable.Map` for the messages state
instead of an object-as-map.
We don't have any specific reason to believe this case exists -- but
our type-checking should have alerted us to the fact that
`messages[id]` might be undefined, as it would start to do when we
store `state.messages` as an `Immutable.Map` in an upcoming commit.
`state.messages` is one thing we'd like to start using `Immutable`
for, and it has numeric keys.

As a practical solution, add another value for
`[SERIALIZED_TYPE_FIELD_NAME]` alongside 'ImmutableMap', called
'ImmutableMapNumericKeys', and have the reviver call `parseInt` on
all the keys in `data`.

This choice should work well, with two assumptions:

- We'll never have an `Immutable.Map` with mixed numeric and string
  keys.

- We won't have a long list of different types we want as keys.

They both seem solid now, but Greg has sketched out some
alternatives in case they break down later [1].

[1] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/persisting.20.60Immutable.2EMap.60s.20with.20numeric.20keys/near/1082872
An instance of zulip#3949 and zulip#3950, like 17bd752.

One important difference from 17bd752 is that the `state.messages`
map has numeric keys. We've handled the replace/revive logic for
that, in the previous commit. But care must be taken whenever we use
an `Immutable.Map(...)` call when we want a map with numeric keys,
If you pass an object-as-map to `Immutable.Map`, it's impossible for
the resulting `Immutable.Map`'s keys to be numbers, because the
object-as-map's keys are necessarily strings, and that's up to
JavaScript. The solution is to pass an array of key-value pairs, but
unfortunately, Flow won't catch it if you forget to do this.
We just removed the last use of this in the previous commit.

It's one of our several helper functions that hasn't yet grown a
jsdoc. This function is quite short and simple, though, so I don't
think it needs to have its own definition, with or without a jsdoc.
Inlining the logic means one less thing to click through to find out
what a piece of code is doing.
@chrisbobbe chrisbobbe force-pushed the pr-immutable-messages branch from 04a6858 to 64b4081 Compare January 14, 2021 02:43
@chrisbobbe
Copy link
Copy Markdown
Contributor Author

I've just rebased across the changes in #4393, fixing a few minor conflicts.

@chrisbobbe

This comment has been minimized.

With the new line added to messagesReducer-test.js, we can confirm
that Immutable returns the identical value if none of the keys
passed to `deleteAll` are present.
Greg points out [1],

"""
A crucial feature here of `update` is that

> if the `updater` function returns the same value it was called
> with, then no change will occur.

So if the message isn't present, the updater will be passed
`undefined`, and just has to return `undefined` and the map will be
returned unchanged.
"""

See also, in that same comment, how Greg considers the effect on
clarity of relying on that subtle fact, and how that weighs with
other concerns, like efficiency.

[1] zulip#4390 (comment)
As Greg points out [1], this means "one fewer hop to look up what
the effect of an action is (or vice versa, to find in what situation
some code found in one of these is called.)".

[1] zulip#4390 (comment)
Like we did for `eventReactionAdd` (before its contents were
inlined) in a recent commit.
Like we did in a recent commit, for `eventReactionAdd`.

This just leaves `eventNewMessage`, but I'd like to try addressing
its optimization TODO, and I'm not sure if the resulting code will
be simple enough to inline.
@gnprice gnprice merged commit 5f0cba4 into zulip:master Jan 14, 2021
@gnprice
Copy link
Copy Markdown
Member

gnprice commented Jan 14, 2021

Thanks for the revision! Looks good -- merged.

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