-
-
Notifications
You must be signed in to change notification settings - Fork 651
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
Presence: Heartbeat #3704
Presence: Heartbeat #3704
Conversation
06ea013
to
23c8f0d
Compare
23c8f0d
to
4e2dd81
Compare
Commenting to make it unambiguous that this is no longer a draft PR. (The exact heartbeat pattern desired is still under discussion, but the structure introduced here isolates that, and should be separately reviewable.) |
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 @ray-kraesig !
We had further chat discussion today about the pattern we want, and I think we have a conclusion:
So I think this means that on going into the background, we should
- not send any update about that; but
- stop sending
active
updates, for as long as we're continuously in the background.And when we come back to the foreground, if it's been over a minute, we should immediately send an
active
update.
92c5578 api [nfc]: rename reportPresence
parameter
This looks good! Feel free to merge whenever.
da9b569 presence: strip throttling from reportPresence
wrapper
This looks like a good implementation of what it says on the tin. I think it may not be part of what we want following today's discussion, though.
Or rather: we do want throttling, I think. (The setInterval
callbacks can accumulate as we switch between accounts, right? Not a good design in itself, but the throttling saves us from the consequences.) I think the idea in this branch is that that'll happen at another layer, which is reasonable. Not sure it simplifies things to pre-emptively take it out of this layer as this separate commit, though.
c196f9d presence: add Heartbeat class (+ tests)
4e2dd81 presence: move all presence-reporting logic into new HeartbeatComponent
I haven't yet read these two in detail, but this general design seems pretty reasonable! Some code comments below.
src/presence/HeartbeatComponent.js
Outdated
|
||
componentWillUnmount() { | ||
AppState.removeEventListener('change', this.onStateChange); | ||
this.heartbeat.stop(); |
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 looks asymmetrical. Should we be starting it in componentDidMount
?
... Ah I see, we do that conditionally via onStateChange
.
How about naming the latter something like updateHeartbeatState
? I.e. named for what it does rather than when it's called. There are a couple of call sites that aren't the "state change" callback, and this name is a little puzzling at both of them.
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.
Works for me. (The asymmetry is worth a couple of comments, too.)
// Note that "PureComponent" is of questionable veracity: this component's | ||
// entire purpose is to emit network calls for their observable side effects. | ||
// However, it is at least true that there is never a need to call `render()` if | ||
// the props haven't changed. | ||
class PresenceHeartbeat extends PureComponent<Props> { |
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 that's actually 100% consistent with what PureComponent
is intended to mean:
https://reactjs.org/docs/react-api.html#reactpurecomponent
After all, almost all components are meant to cause something to appear in the UI; and any component with a widget like a button (that does something) is capable of causing quite general side effects, often a network request. All of that is independent of being a PureComponent
. So the sense of "pure" in PureComponent
has to be a lot narrower than "this is side-effect-free code".
I think in fact it really runs only in the dual direction: it says it's free of suffering certain kinds of side effects, but nothing about what side effects it may cause.
It could perhaps have a more lucid name than it does. 🙂
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.
After all, almost all components are meant to cause something to appear in the UI; and any component with a widget like a button (that does something) is capable of causing quite general side effects, often a network request.
This is true, as far as it goes – but PureComponent.render()
in particular is expected to do precisely none of those things, and instead only to return a specification of how and when one might later go about doing them.
That expectation is flagrantly violated by this component, whose .render()
can, and often will, fire off a network request before returning. It's less pure than many Component.render()
functions. The kindest thing one can say about it, I think, is that it is idempotent even when considered as a function from World
to World
.
(Well, modulo modifications of this
and its prototype chain. But if you're breaking the equivalence of this.foo.bind(this)
and () => this.foo()
, then nothing holds anyway.)
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 expectation is flagrantly violated by this component, whose
.render()
can, and often will, fire off a network request before returning.
Hmm, I see.
I think that's a sign that render
isn't the right place to put that call -- quite independently of PureComponent
vs. its base class Component
.
Consulting the API docs:
https://reactjs.org/docs/react-component.html
I think componentDidUpdate
is the intended place to put this kind of call. That's based on its own description, and also on "use this instead" pointers to it in the descriptions of getDerivedStateFromProps
and the deprecated UNSAFE_componentWillReceiveProps
.
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.
👍
(The docs aren't explicit as to whether componentDidUpdate
will fire if the post-render()
reconciliation phase says there are no changes, but it seems that it will.)
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.
Oof, that's a regrettable form for documentation to be in. :-/ Glad you tracked it down -- please mention it somewhere so that we, at least, can hang onto that information.
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.
Now that render
just returns null, I think this stack pops down to my first comment.
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.
It is now the case that this component performs network calls as a side effect of render()
, rather than in render()
directly.
Modulo that change, I think this warning remains appropriate.
I'm not entirely convinced that this is the right approach: it seems like the server could synthesize that from the more precise and accurate information stream (i.e., one with "idle" events) with very little overhead. An implementing commit
It's one less thing in the |
The thing is that "idle" doesn't mean "the user went away just now". Rather it means "hi, still here, but btw the user hasn't interacted with me (this client) recently." ... But really the better place for that design discussion is the chat thread; here we're in danger of overflowing what works well on a GitHub thread, as well as just getting mixed with the details of code. Want to follow up there? |
Mobile apps are not usually described as "having focus", and the underlying parameter value is `"active"` anyway.
The throttling logic currently loses, rather than delaying, an `idle` which terminates a stream of `active` presence reports. Remove it. Technically, this alone would fix zulip#3699 as literally reported, since the `idle` report is now emitted. However, the `active`-emitting `setInterval` callback is still present and may still fire off events. (Whether it actually does or not is presumably up to the underlying OS.)
Add `Heartbeat`, a generic-looking class which will be used to perform presence-reporting. Also add tests, to ensure that it behaves as expected.
Centralize all presence-reporting logic into a new zero-display React component, currently (and somewhat arbitrarily) located under AppStateHandlers. This fixes at least two issues: * If the user logs in and out, or otherwise performs an additional "initial fetch" without killing the app, the presence timer will be started multiple times. (This was previously concealed by throttling logic in usersActions.reportPresence, which was itself removed in a previous patch in this set.) * If the user goes idle (e.g. by hitting the Home button), the presence timer will still run, and will still attempt to send `active` notifications. (These attempts are often suppressed while the app is in the background, but there's no guarantee of this.) Fixes zulip#3699 (properly), and is work towards zulip#3659. Unfortunately, there is no user-visible change yet: modifications will be needed on the Zulip server side as well.
Jest doesn't currently (v24.9.0) mock out `Date.now()` when using fake timers. This is expected to change in 25.0 when Jest switches over to using Lolex for its fake timer implementation. We need it today, though, so we'll jump the gun on that.
Although we don't make use of Date.now in the current implementation of Heartbeat, we're about to. Convert its tests to use Lolex, so that Date.now() will be properly faked. Rather than completely rewrite all the timer calls to use Lolex's API directly (since we'd just end up changing them back once we can upgrade Jest), we use a small shim, taken from the code on Jest's current master branch.
Following discussion on chat.zulip.org's #mobile-team channel, adjust the presence-reporting protocol so that it never sends "idle". https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/presence.20and.20notifications
The heartbeat callback is now only ever called with `true`. Simplify the associated code by removing the now-constant argument.
4e2dd81
to
f013a72
Compare
Updated with new presence protocol, new tests, and a (hopefully very temporary) new dev-dependency. |
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 @ray-kraesig ! Comments below. Nearly all are only about comments and names and such, other than the very last thing I spotted.
One (equally small) comment that's on a commit message and has no natural home in the code:
heartbeat: change presence-reporting protocol
Following discussion on chat.zulip.org's #mobile-team channel, adjust
the presence-reporting protocol so that it never sends "idle".
https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/presence.20and.20notifications
-
s/channel/stream/ :wink:
-
Should include a
near
operator, i.e. link to a specific message -- otherwise for someone caught up this goes to the latest, which could in the future be much later than you intend. (And it goes to the first unread if any, which is probably also less helpful than linking to a specific message at the point where we reached the relevant conclusion.)
} | ||
|
||
// React to any state change. | ||
updateHeartbeatState: () => void = () => { |
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 line threw me briefly because the two =>
made it look like it's a nested lambda.
Our usual style would be to write the types at the lambda:
updateHeartbeatState = (): void => {
or actually more typically to not mention the void
return type here:
updateHeartbeatState = () => {
return <View style={styles.wrapper}>{this.props.children}</View>; | ||
return ( | ||
<> | ||
<HeartbeatComponent /> |
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 it'd be helpful to have the word "presence" in the name -- that's the Zulip jargon term for this subsystem, so it's the quickest way to make clear what the general purpose of this component is.
... And then I see the class is actually already named PresenceHeartbeat
! 😄 That'd be a good name for the module.
return ( | ||
<> | ||
<HeartbeatComponent /> | ||
<View style={styles.wrapper}>{this.props.children}</View> |
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.
Seeing the render () { return null; }
style in the new component, it occurs to me it might be helpful -- might make things a bit clearer -- to pull out the rest of the work of AppEventHandlers
as a component with a similar null render, to be included as a sibling rather than a parent of the rest of the app.
(As a followup, I think.)
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.
(from offline) More like four or five different sibling components, I think. Agreed otherwise.
// Note that "PureComponent" is of questionable veracity: this component's | ||
// entire purpose is to emit network calls for their observable side effects. | ||
// However, it is at least true that there is never a need to call `render()` if | ||
// the props haven't changed. | ||
class PresenceHeartbeat extends PureComponent<Props> { |
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.
Now that render
just returns null, I think this stack pops down to my first comment.
// N.B.: If `auth` changes, we do not send out a final `false` presence | ||
// status for the previous `auth`. It's the responsibility of our logout | ||
// handler to determine whether that's necessary. | ||
// | ||
// (TODO: ensure that our logout handlers actually do that.) |
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 comment is out of date, right? Especially the "responsibility" / TODO bits. The first bit remains literally true, but is now confusing instead of helpful 🙂
@@ -108,6 +108,7 @@ | |||
"jest-environment-jsdom": "^24.9.0", | |||
"jest-environment-jsdom-global": "^1.2.0", | |||
"jest-extended": "^0.11.2", | |||
"lolex": "^5.1.1", |
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.
Jest doesn't currently (v24.9.0) mock out `Date.now()` when using fake
timers. This is expected to change in 25.0 when Jest switches over to
using Lolex for its fake timer implementation.
Oh neat!
We were looking the other day at some Jest threads on this subject, I think, and didn't spot this news then. Where did you find it?
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 would be a helpful link for the commit message.)
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.
Ahh, I see, the next commit has the answer.
Simplest thing may be to just squash the two, as the deps commit is very small.
Also unfortunately my reading of that Jest PR would not be as optimistic as this. AFAICT there's been no reply from anyone at FB in the 10 months that PR has been open. Landing this PR, and doing so for Jest v25, is the author's plan but it's not clear it's anyone else's.
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.
Simplest thing may be to just squash the two, as the deps commit is very small.
Done!
Also unfortunately my reading of that Jest PR would not be as optimistic as this.
The author in question is a collaborator for Jest (i.e., has commit privileges on the repo), so I have somewhat greater hopes of seeing this in for v25.
(The new message for the squashed commit is somewhat more measured, nonetheless.)
@@ -55,7 +55,7 @@ class Lolex { | |||
let lolex: Lolex; | |||
|
|||
// type alias for Jest callback functions of type (boolean) => void | |||
type CallbackType = JestMockFn<$ReadOnlyArray<boolean>, void>; | |||
type CallbackType = JestMockFn<$ReadOnlyArray<void>, void>; |
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 comment is out of date 🙂
Simplest fix is to update it in tandem.
But I think this also makes an excellent example of why it's good to avoid repeating details of the code in the comments. Here, the information this is really trying to add is something like "those type arguments are the types of the mock function's parameters and return." One way to say that would be
// Type parameters are: JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn>
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.
Unfortunately *
is deprecated. In particular I'm not sure that wouldn't have the effect of ReadOnlyArray<any>
, which – while accurate for Jest – is probably not what I want here.
// Heartbeat erases its callback type (and it should probably be private | ||
// anyway), so the `expectRunning` and `expectNotRunning` helpers take the | ||
// callback mock as a separate well-typed 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.
Huh, I'm puzzled -- what do you mean by saying it erases its callback type? Here's the definition in the early part of the branch:
class Heartbeat {
intervalId: IntervalID | null = null;
callback: (state: boolean) => void;
milliseconds: number;
and a later commit does s/state: boolean//
.
It seems like the type is right there. What am I missing?
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's not the full type, though; it's just the maximal supertype. The "underlying type" of the callback
object here is CallbackType
.
Not erasing the type would involve giving Heartbeat
a generic type parameter T: (boolean) => void
(later () => void
). This isn't difficult, of course; but it's also pointless unless callback
is part of Heartbeat
's public interface. (Which it really shouldn't be!)
if (this.previousTime + this.milliseconds <= Date.now()) { | ||
this.doCallback(); | ||
} | ||
this.intervalId = setInterval(this.doCallback, this.milliseconds); | ||
} |
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 this can have a gap of up to twice nominal, if the state flaps shortly before the regular timer would fire.
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.
2 - ε, yes, as mentioned in chat... whereupon we talked a great deal about the possibility of too many presence signals, but much less about the possibility of not enough. :/
After a bit of poking and refactoring... hilariously, it looks like the simplest way to minimize that gap also removes any reason to call Date.now
, and therefore makes Lolex useless here. Yay? ¯\_(ツ)_/¯
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.
Addendum: the above was not true, and Lolex remains useful.
BTW I also just added a link in the PR description to #3699, so that that thread gets an autolink back here. I was making a comment there, wanted to refer to this PR, and found I didn't have a reference to it 🙂 |
The new version is rather larger, as it contains more tests than were originally thought viable... and this GitHub PR is already approaching the limit of usefulness. Closing, to be reopened with a fresh slate and a pointer. |
Draft PR to centralize presence-reporting code. Not yet suitable for merge due to, at a minimum, the reasons noted in the final commit.This may not be the heartbeat pattern we actually want, but if not, it should be easy to replace.
[edit by @gnprice: Fixes (or moots) #3699 ; part of addressing #3659 .]