Skip to content

Commit 0f67ea8

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` (#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 #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 2db40f4 commit 0f67ea8

File tree

6 files changed

+58
-42
lines changed

6 files changed

+58
-42
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/model/store_test.dart

-21
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import 'package:zulip/api/model/model.dart';
1212
import 'package:zulip/api/route/events.dart';
1313
import 'package:zulip/api/route/messages.dart';
1414
import 'package:zulip/api/route/realm.dart';
15-
import 'package:zulip/model/message_list.dart';
16-
import 'package:zulip/model/narrow.dart';
1715
import 'package:zulip/log.dart';
1816
import 'package:zulip/model/store.dart';
1917
import 'package:zulip/notifications/receive.dart';
@@ -824,25 +822,6 @@ void main() {
824822
checkReload(prepareHandleEventError);
825823
});
826824

827-
test('expired queue disposes registered MessageListView instances', () => awaitFakeAsync((async) async {
828-
// Regression test for: https://github.com/zulip/zulip-flutter/issues/810
829-
await preparePoll();
830-
831-
// Make sure there are [MessageListView]s in the message store.
832-
MessageListView.init(store: store, narrow: const MentionsNarrow());
833-
MessageListView.init(store: store, narrow: const StarredMessagesNarrow());
834-
check(store.debugMessageListViews).length.equals(2);
835-
836-
// Let the server expire the event queue.
837-
prepareExpiredEventQueue();
838-
updateMachine.debugAdvanceLoop();
839-
async.elapse(Duration.zero);
840-
841-
// The old store's [MessageListView]s have been disposed.
842-
// (And no exception was thrown; that was #810.)
843-
check(store.debugMessageListViews).isEmpty();
844-
}));
845-
846825
group('report error', () {
847826
String? lastReportedError;
848827
String? takeLastReportedError() {

test/widgets/message_list_test.dart

+46
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ 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/exception.dart';
1011
import 'package:zulip/api/model/events.dart';
1112
import 'package:zulip/api/model/initial_snapshot.dart';
1213
import 'package:zulip/api/model/model.dart';
1314
import 'package:zulip/api/model/narrow.dart';
1415
import 'package:zulip/api/route/messages.dart';
16+
import 'package:zulip/model/actions.dart';
1517
import 'package:zulip/model/localizations.dart';
1618
import 'package:zulip/model/narrow.dart';
1719
import 'package:zulip/model/store.dart';
@@ -56,6 +58,7 @@ void main() {
5658
List<Subscription>? subscriptions,
5759
UnreadMessagesSnapshot? unreadMsgs,
5860
List<NavigatorObserver> navObservers = const [],
61+
bool skipAssertAccountExists = false,
5962
}) async {
6063
TypingNotifier.debugEnable = false;
6164
addTearDown(TypingNotifier.debugReset);
@@ -77,6 +80,7 @@ void main() {
7780
eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson());
7881

7982
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
83+
skipAssertAccountExists: skipAssertAccountExists,
8084
navigatorObservers: navObservers,
8185
child: MessageListPage(initNarrow: narrow)));
8286

@@ -130,6 +134,48 @@ void main() {
130134
final state = MessageListPage.ancestorOf(tester.element(find.text("a message")));
131135
check(state.composeBoxController).isNull();
132136
});
137+
138+
testWidgets('dispose MessageListView when event queue expired', (tester) async {
139+
// Regression test for: https://github.com/zulip/zulip-flutter/issues/810
140+
final message = eg.streamMessage();
141+
await setupMessageListPage(tester, messages: [message]);
142+
final oldViewModel = store.debugMessageListViews.single;
143+
final updateMachine = store.updateMachine!;
144+
updateMachine.debugPauseLoop();
145+
updateMachine.poll();
146+
147+
updateMachine.debugPrepareLoopError(ZulipApiException(
148+
routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID',
149+
data: {'queue_id': updateMachine.queueId}, message: 'Bad event queue ID.'));
150+
updateMachine.debugAdvanceLoop();
151+
await tester.pump();
152+
// Event queue has been replaced; but the [MessageList] hasn't been
153+
// rebuilt yet.
154+
final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
155+
check(connection.isOpen).isFalse(); // indicates that the old store has been disposed
156+
check(store.debugMessageListViews).single.equals(oldViewModel);
157+
check(newStore.debugMessageListViews).isEmpty();
158+
159+
(newStore.connection as FakeApiConnection).prepare(json: eg.newestGetMessagesResult(
160+
foundOldest: true, messages: [message]).toJson());
161+
await tester.pump();
162+
await tester.pump(Duration.zero);
163+
// As [MessageList] rebuilds, the old view model gets disposed and
164+
// replaced with a fresh one.
165+
check(store.debugMessageListViews).isEmpty();
166+
check(newStore.debugMessageListViews).single.not((it) => it.equals(oldViewModel));
167+
});
168+
169+
testWidgets('dispose MessageListView when logged out', (tester) async {
170+
await setupMessageListPage(tester,
171+
messages: [eg.streamMessage()], skipAssertAccountExists: true);
172+
check(store.debugMessageListViews).single;
173+
174+
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
175+
await tester.pump(TestGlobalStore.removeAccountDuration);
176+
await future;
177+
check(store.debugMessageListViews).isEmpty();
178+
});
133179
});
134180

135181
group('app bar', () {

0 commit comments

Comments
 (0)