-
Notifications
You must be signed in to change notification settings - Fork 263
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
store: Ensure sole ownership of MessageListView #1340
Conversation
ed21485
to
af0cc46
Compare
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! LGTM, with an indentation nit below. Another small comment: since there's no issue to refer to (with a "Fixes" line), could the commit message either link to the discussion or say something like "see discussion linked in a code comment"?
lib/model/actions.dart
Outdated
final token = account.ackedPushToken | ||
// Try the current token as a fallback; maybe the server has registered | ||
// it and we just haven't recorded that fact in the client. | ||
?? NotificationService.instance.token.value; |
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.
nit: indentation
Updated the PR with a copy of the discussion link in the commit message, and fixed the indentation issue. |
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 and fixing this!
I believe the implementation logic here is all correct. But I'd like to see an assert, and one of these pieces still needs a test. See also other comments below.
test/widgets/actions_test.dart
Outdated
@@ -109,7 +111,7 @@ void main() { | |||
final newConnection = separateConnection() | |||
..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'}); | |||
|
|||
final future = logOutAccount(context, eg.selfAccount.id); | |||
final future = logOutAccount(GlobalStoreWidget.of(context), eg.selfAccount.id); |
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.
nit: use the global store we already have:
final future = logOutAccount(GlobalStoreWidget.of(context), eg.selfAccount.id); | |
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); |
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.
In particular that will facilitate migrating these tests to not use a widget tree. Since the code they're testing isn't about widgets (which is why we're moving it out of lib/widgets/), they shouldn't need to involve widgets either.
test/widgets/actions_test.dart
Outdated
@@ -12,6 +12,7 @@ import 'package:zulip/api/model/initial_snapshot.dart'; | |||
import 'package:zulip/api/model/model.dart'; | |||
import 'package:zulip/api/model/narrow.dart'; | |||
import 'package:zulip/api/route/messages.dart'; | |||
import 'package:zulip/model/actions.dart'; |
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.
When moving the code, we should move its tests to match. (We're moving these methods because they don't really belong under lib/widgets/; so their tests don't belong under test/widgets/ either.)
test/widgets/actions_test.dart
Outdated
@@ -211,7 +213,7 @@ void main() { | |||
testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route); | |||
|
|||
final context = tester.element(find.byType(MaterialApp)); | |||
final future = logOutAccount(context, account1.id); | |||
final future = logOutAccount(GlobalStoreWidget.of(context), account1.id); | |||
await tester.pump(TestGlobalStore.removeAccountDuration); | |||
await future; | |||
check(removedRoutes).single.identicalTo(account1Route); |
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.
OTOH this test is about widgets. Which I guess underlines the fact that it's not really about this code at all, but an integration test of how other related code behaves when this happens.
Maybe the right home for this one is in widgets/store_test.dart
. The implementation of the logic this is testing lives mainly on PerAccountStoreWidget, in the handling of routeToRemoveOnLogout
.
lib/model/message.dart
Outdated
view.dispose(); | ||
} | ||
} | ||
// No `dispose` method, because there's nothing for it to do. |
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.
With two owners, the MessageListView can be disposed twice:
1. before the frame is rendered, `removeAccount` disposes the
`PerAccountStoreWidget`, which disposes the `MessageListView`;
Do you mean PerAccountStore
rather than PerAccountStoreWidget
?
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. during build, because `store` is set to `null`,
`PerAccountStoreWidget` gets rebuilt. `_MessageListState`,
This isn't quite right — if we didn't have this line (which I believe is what you're referring to as "store
is set to null
"):
_setStore(null);
then that element would still get rebuilt. That's because that line is within that state's didChangeDependencies
, and the base method's doc says:
/// Subclasses rarely override this method because the framework always
/// calls [build] after a dependency changes. Some subclasses do override
So if that method was getting called, then build
was going to get called next anyway.
It's a good small exercise to work out exactly what does cause build
to get called in this scenario.
lib/model/message.dart
Outdated
view.dispose(); | ||
} | ||
} | ||
// No `dispose` method, because there's nothing for it to do. |
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.
`PerAccountStoreWidget` gets rebuilt. `_MessageListState`,
a descendent of it, is no longer in the render tree;
nit: element tree, not render tree — the render tree is a different thing:
https://main-api.flutter.dev/flutter/rendering/RenderObject-class.html
and a _MessageListState
doesn't directly correspond to anything in the render tree (i.e. to any render object).
lib/model/message.dart
Outdated
view.dispose(); | ||
} | ||
} | ||
// No `dispose` method, because there's nothing for it to do. |
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.
nit:
PerAccountStore shouldn't be an owner of the MessageListView elements.
A MessageListView isn't an "element"; that term has a specific meaning in Flutter:
https://main-api.flutter.dev/flutter/widgets/Element-class.html
and is also used (following math terminology) for an item in a collection data structure, like a List, but it's not a general synonym for "object" or "value".
Instead of "elements", "objects" would be accurate.
lib/model/message.dart
Outdated
// the [_MessageListState], including in the case where the [PerAccountStore] | ||
// is replaced. Discussion: | ||
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/.60MentionAutocompleteView.2Edispose.60/near/2083074 | ||
// void dispose() { … } |
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 makes me a bit nervous that it would be easy for us to spring a leak where MessageListView objects stick around. They can be memory-hungry, I think, so that's good to avoid.
That risk is highlighted by the fact that you had to add a model?.dispose();
line elsewhere in this commit — we had such a line when the state gets disposed, but had omitted to add one for when it gets a new store.
How about we instead have a dispose
method here but it just asserts that its set of MessageListView objects to manage is empty? I believe that's implied by the logic we're relying on in order to justify not needing to dispose them here:
[…] But it also is tied to a given
PerAccountStore
, and so shouldn't outlive that store. So it should get disposed when the store goes away, too.I think that might already be getting accomplished by the
dispose
calls in_ComposeAutocompleteState
. If the store is getting disposed of, then it shouldn't be the case that a_ComposeAutocompleteState
sticks around in the tree under that store — any such_ComposeAutocompleteState
should either be told of a new store withonNewStore
, or be leaving the tree entirely and so have its owndispose
called. Both of those calldispose
on the view-model.
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.
Followed up on CZO and we decided to leave a TODO on this for now.
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.
Yeah. As a nit, let's keep the method, though — it'll just have an empty body containing that TODO. That provides a bit more framing to make clear exactly what the TODO calls for, and it also means we preserve the record of what's meant to call this method.
@@ -824,25 +822,6 @@ void main() { | |||
checkReload(prepareHandleEventError); | |||
}); | |||
|
|||
test('expired queue disposes registered MessageListView instances', () => awaitFakeAsync((async) async { |
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 scenario is still highly relevant — see my discussion above of the potential for a leak.
So we should still test it. The change to make is just that the test now needs appropriate widgets as part of the setup, because the MessageList widget is now part of what makes this behave correctly.
@@ -483,6 +483,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat | |||
|
|||
@override | |||
void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages | |||
model?.dispose(); |
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.
If this line is removed, it looks like all tests still pass. There should be a test that would notice if we dropped this.
(I think this is exactly what the test discussed in my previous comment would do.)
0f67ea8
to
2f7a67b
Compare
Updated the PR to address the comments. Thanks for the review! The insights on the Flutter's widgets/elements are really helpful. |
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 tracking down these subtle interactions!
This all looks good now, modulo a few small comments.
lib/model/message.dart
Outdated
// the [_MessageListState], including in the case where the [PerAccountStore] | ||
// is replaced. Discussion: | ||
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/.60MentionAutocompleteView.2Edispose.60/near/2083074 | ||
// void dispose() { … } |
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.
Yeah. As a nit, let's keep the method, though — it'll just have an empty body containing that TODO. That provides a bit more framing to make clear exactly what the TODO calls for, and it also means we preserve the record of what's meant to call this method.
lib/model/message.dart
Outdated
view.dispose(); | ||
} | ||
} | ||
// No `dispose` method, because there's nothing for it to do. |
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.
nit:
2. Being dependent on `GlobalStore`, `PerAccountStoreWidget` is rebuilt.
This time, the StatefulElement corresponding to the `MessageList`
widget, is no longer in the element tree because `PerAccountStoreWidget`
cannot find the account and builds a placeholder instead.
It'd be good to call out explicitly at the start of this scenario (before the numbered steps) that you're talking about a case where removeAccount
is called, like if the user logs out. I read this step a couple of times and initially thought it was mistaken, because the main scenario that comes to my mind for a store getting disposed is when it's replaced by a new store.
lib/model/message.dart
Outdated
// TODO: Add assertions that the [MessageListView]s are indeed disposed, by | ||
// first ensuring that [PerAccountStore] is only disposed after those with | ||
// references to it are disposed, then reinstating this `dispose` method. | ||
// Discussion: | ||
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/.60MentionAutocompleteView.2Edispose.60/near/2083074 |
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.
nit: let's have this point here:
https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893
since that's where the discussion relevant to this particular TODO begins.
test/widgets/message_list_test.dart
Outdated
@@ -130,6 +134,48 @@ void main() { | |||
final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); | |||
check(state.composeBoxController).isNull(); | |||
}); | |||
|
|||
testWidgets('dispose MessageListView when event queue expired', (tester) async { | |||
// Regression test for: https://github.com/zulip/zulip-flutter/issues/810 |
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 isn't a regression test for #810 anymore — to do that, there'd have to be more than one MessageListView.
That's fine, though. We don't really need a regression test for that, now that the code that the bug was in (the loop through view-models in MessageStoreImpl.dispose) is gone. Instead this is just testing the thing the test's name says it is.
// Regression test for: https://github.com/zulip/zulip-flutter/issues/810 |
Thanks! Updated the PR. |
lib/model/message.dart
Outdated
// [GlobalStore] notifies its listeners, causing widgets dependent on the | ||
// [InheritedNotifier] to rebuild in the next frame) before the owner's | ||
// `dispose` or `onNewStore` is called. Discussion: | ||
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/.60MentionAutocompleteView.2Edispose.60/near/2083074 |
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.
bump #1340 (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.
Hmm, I read that as "have this point, here" and incorporated this new paragraph. I think you are meant to have the link to "point here", right?
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 see. Yeah, I meant "have this link point to here", i.e. to the following URL.
b2aa4a6
to
d88a995
Compare
Updated the references to the CZO discussion with the link from #1340 (comment). |
This allows us to call it from model code when GlobalStore is available. Signed-off-by: Zixuan James Li <[email protected]>
This is more about testing the implementation of PerAccountStoreWidget to handle routeToRemoveOnLogout, instead of logOutAccount. Signed-off-by: Zixuan James Li <[email protected]>
plus moving and refactoring the tests to match Signed-off-by: Zixuan James Li <[email protected]>
`PerAccountStore` shouldn't be an owner of the `MessageListView` objects. Its relationship to `MessageListView` is similar to that of `AutocompleteViewManager` to `MentionAutocompleteView` (zulip#645). With two owners, `MessageListView` can be disposed twice when `removeAccount` is called (when the user logs out, for example): 1. Before the frame is rendered, after removing the `PerAccountStore` from `GlobalStore`, `removeAccount` disposes the `PerAccountStore` , which disposes the `MessageListView` (via `MessageStoreImpl`). `removeAccount` also notifies the listeners of `GlobalStore`. At this point `_MessageListState` is not yet disposed. 2. Being dependent on `GlobalStore`, `PerAccountStoreWidget` is rebuilt. This time, the StatefulElement corresponding to the `MessageList` widget, is no longer in the element tree because `PerAccountStoreWidget` cannot find the account and builds a placeholder instead. 3. During finalization, `_MessageListState` tries to dispose the `MessageListView`, and fails to do so. We couldn't've kept `MessageStoreImpl.dispose` with an assertion `_messageListView.isEmpty`, because `PerAccountStore` is disposed before `_MessageListState.dispose` (and similarily `_MessageListState.onNewStore`) is called. Fixing that will be a future follow-up to this, as noted in the TODO comment. See discussion: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893 Signed-off-by: Zixuan James Li <[email protected]>
Thanks! Looks good; merging. |
PerAccountStore shouldn't be an owner of the MessageListView elements.
Its relationship to MessageListView is similar to that of AutocompleteViewManager to MentionAutocompleteView (#645).
With two owners, the MessageListView can be disposed twice:
before the frame is rendered,
removeAccount
disposes thePerAccountStoreWidget
, which disposes theMessageListView
;_MessageListState
is not yet disposed;during build, because
store
is set tonull
,PerAccountStoreWidget
gets rebuilt._MessageListState
, a descendent of it, is no longer in the render tree;during finalization,
_MessageListState
tries to dispose theMessageListView
.This removes regression tests added for #810, because
MessageStoreImpl.dispose
no longer exists.MessageListView
does not get disposed unless there is a_MessageListState
owner.For reproduction, I created a testing branch with #1183 stacked on top of this, but has the fix reverted:
MessageListPage
You should see the following error:
CZO discussion