Skip to content

Commit da6354b

Browse files
committed
msglist: Ensure sole ownership of MessageListView
`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: 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. A regression test for zulip#810 has been appropriated. The original issue is relevant because the scenario this integration test targeted still applies after this change. See discussion: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2083074 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 59726db commit da6354b

File tree

5 files changed

+57
-21
lines changed

5 files changed

+57
-21
lines changed

lib/model/message.dart

+11-8
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,17 @@ class MessageStoreImpl with MessageStore {
6060
}
6161
}
6262

63-
void dispose() {
64-
// When a MessageListView is disposed, it removes itself from the Set
65-
// `MessageStoreImpl._messageListViews`. Instead of iterating on that Set,
66-
// iterate on a copy, to avoid concurrent modifications.
67-
for (final view in _messageListViews.toList()) {
68-
view.dispose();
69-
}
70-
}
63+
// No `dispose` method, because there's nothing for it to do.
64+
// Not disposing the [MessageListView]s here, because they are owned by
65+
// (i.e., they get [dispose]d by) the [_MessageListState], including in the
66+
// case where the [PerAccountStore] is replaced.
67+
//
68+
// TODO: Add assertions that the [MessageListView]s are indeed disposed, by
69+
// first ensuring that [PerAccountStore] is only disposed after those
70+
// with references to it are disposed, then reinstate this `dispose` method.
71+
// Discussion:
72+
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/.60MentionAutocompleteView.2Edispose.60/near/2083074
73+
// void dispose() { … }
7174

7275
@override
7376
void reconcileMessages(List<Message> messages) {

lib/model/store.dart

-1
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,6 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
557557
assert(!_disposed);
558558
recentDmConversationsView.dispose();
559559
unreads.dispose();
560-
_messages.dispose();
561560
typingStatus.dispose();
562561
typingNotifier.dispose();
563562
updateMachine?.dispose();

lib/widgets/message_list.dart

+1
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
483483

484484
@override
485485
void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages
486+
model?.dispose();
486487
_initModel(PerAccountStoreWidget.of(context));
487488
}
488489

test/model/message_test.dart

-12
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,6 @@ void main() {
7777
checkNotified(count: messageList.fetched ? messages.length : 0);
7878
}
7979

80-
test('disposing multiple registered MessageListView instances', () async {
81-
// Regression test for: https://github.com/zulip/zulip-flutter/issues/810
82-
await prepare(narrow: const MentionsNarrow());
83-
MessageListView.init(store: store, narrow: const StarredMessagesNarrow());
84-
check(store.debugMessageListViews).length.equals(2);
85-
86-
// When disposing, the [MessageListView]s are expected to unregister
87-
// themselves from the message store.
88-
store.dispose();
89-
check(store.debugMessageListViews).isEmpty();
90-
});
91-
9280
group('reconcileMessages', () {
9381
test('from empty', () async {
9482
await prepare();

test/widgets/message_list_test.dart

+45
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import 'package:flutter/rendering.dart';
77
import 'package:flutter_checks/flutter_checks.dart';
88
import 'package:flutter_test/flutter_test.dart';
99
import 'package:http/http.dart' as http;
10+
import 'package:zulip/api/backoff.dart';
11+
import 'package:zulip/api/exception.dart';
1012
import 'package:zulip/api/model/events.dart';
1113
import 'package:zulip/api/model/initial_snapshot.dart';
1214
import 'package:zulip/api/model/model.dart';
1315
import 'package:zulip/api/model/narrow.dart';
1416
import 'package:zulip/api/route/messages.dart';
17+
import 'package:zulip/model/actions.dart';
1518
import 'package:zulip/model/localizations.dart';
1619
import 'package:zulip/model/narrow.dart';
1720
import 'package:zulip/model/store.dart';
@@ -132,6 +135,48 @@ void main() {
132135
final state = MessageListPage.ancestorOf(tester.element(find.text("a message")));
133136
check(state.composeBoxController).isNull();
134137
});
138+
139+
testWidgets('dispose MessageListView when event queue expired', (tester) async {
140+
// Regression test for: https://github.com/zulip/zulip-flutter/issues/810
141+
final message = eg.streamMessage();
142+
await setupMessageListPage(tester, messages: [message]);
143+
final oldViewModel = store.debugMessageListViews.single;
144+
final updateMachine = store.updateMachine!;
145+
updateMachine.debugPauseLoop();
146+
updateMachine.poll();
147+
148+
updateMachine.debugPrepareLoopError(ZulipApiException(
149+
routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID',
150+
data: {'queue_id': updateMachine.queueId}, message: 'Bad event queue ID.'));
151+
updateMachine.debugAdvanceLoop();
152+
await tester.pump();
153+
// Event queue has been replaced; but the [MessageList] hasn't been
154+
// rebuilt yet.
155+
final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
156+
check(connection.isOpen).isFalse(); // indicates that the old store has been disposed
157+
check(store.debugMessageListViews).single.equals(oldViewModel);
158+
check(newStore.debugMessageListViews).isEmpty();
159+
160+
(newStore.connection as FakeApiConnection).prepare(json: eg.newestGetMessagesResult(
161+
foundOldest: true, messages: [message]).toJson());
162+
await tester.pump();
163+
await tester.pump(Duration.zero);
164+
// As [MessageList] rebuilds, the old view model gets disposed and
165+
// replaced with a fresh one.
166+
check(store.debugMessageListViews).isEmpty();
167+
check(newStore.debugMessageListViews).single.not((it) => it.equals(oldViewModel));
168+
});
169+
170+
testWidgets('dispose MessageListView when logged out', (tester) async {
171+
await setupMessageListPage(tester,
172+
messages: [eg.streamMessage()], skipAssertAccountExists: true);
173+
check(store.debugMessageListViews).single;
174+
175+
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
176+
await tester.pump(TestGlobalStore.removeAccountDuration);
177+
await future;
178+
check(store.debugMessageListViews).isEmpty();
179+
});
135180
});
136181

137182
group('app bar', () {

0 commit comments

Comments
 (0)