Skip to content

Commit a055486

Browse files
PIG208gnprice
authored andcommitted
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 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]>
1 parent d186ed0 commit a055486

File tree

5 files changed

+60
-39
lines changed

5 files changed

+60
-39
lines changed

lib/model/message.dart

+14-6
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,20 @@ class MessageStoreImpl with MessageStore {
6161
}
6262

6363
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-
}
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 with
70+
// references to it are disposed, then reinstating this `dispose` method.
71+
//
72+
// We can't add the assertions as-is because the sequence of events
73+
// guarantees that `PerAccountStore` is disposed (when that happens,
74+
// [GlobalStore] notifies its listeners, causing widgets dependent on the
75+
// [InheritedNotifier] to rebuild in the next frame) before the owner's
76+
// `dispose` or `onNewStore` is called. Discussion:
77+
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893
7078
}
7179

7280
@override

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

+45
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,47 @@ 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+
final message = eg.streamMessage();
140+
await setupMessageListPage(tester, messages: [message]);
141+
final oldViewModel = store.debugMessageListViews.single;
142+
final updateMachine = store.updateMachine!;
143+
updateMachine.debugPauseLoop();
144+
updateMachine.poll();
145+
146+
updateMachine.debugPrepareLoopError(ZulipApiException(
147+
routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID',
148+
data: {'queue_id': updateMachine.queueId}, message: 'Bad event queue ID.'));
149+
updateMachine.debugAdvanceLoop();
150+
await tester.pump();
151+
// Event queue has been replaced; but the [MessageList] hasn't been
152+
// rebuilt yet.
153+
final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
154+
check(connection.isOpen).isFalse(); // indicates that the old store has been disposed
155+
check(store.debugMessageListViews).single.equals(oldViewModel);
156+
check(newStore.debugMessageListViews).isEmpty();
157+
158+
(newStore.connection as FakeApiConnection).prepare(json: eg.newestGetMessagesResult(
159+
foundOldest: true, messages: [message]).toJson());
160+
await tester.pump();
161+
await tester.pump(Duration.zero);
162+
// As [MessageList] rebuilds, the old view model gets disposed and
163+
// replaced with a fresh one.
164+
check(store.debugMessageListViews).isEmpty();
165+
check(newStore.debugMessageListViews).single.not((it) => it.equals(oldViewModel));
166+
});
167+
168+
testWidgets('dispose MessageListView when logged out', (tester) async {
169+
await setupMessageListPage(tester,
170+
messages: [eg.streamMessage()], skipAssertAccountExists: true);
171+
check(store.debugMessageListViews).single;
172+
173+
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
174+
await tester.pump(TestGlobalStore.removeAccountDuration);
175+
await future;
176+
check(store.debugMessageListViews).isEmpty();
177+
});
133178
});
134179

135180
group('app bar', () {

0 commit comments

Comments
 (0)