Skip to content

Commit 4f22b91

Browse files
chrisbobbeclaude
andcommitted
msglist: Suppress duplicate message on fetch/event race
Fixes #929. When the user navigates to a narrow, the message fetch can race with the event queue: a newly sent message appears in the fetch response and then later arrives as a message event. Before now, the message would get shown twice in the message list; this commit fixes that. On handling a message event, when the message is already in the list, skip adding a duplicate. Do adopt the event's copy of the message, though, because the message store has done the same, and the list's messages should remain identical to the store's so that the message reflects subsequent update events. Do this adoption before the narrow/haveNewest checks that gate adding a *new* message. Those checks answer whether a new message belongs in the list; but a message we already have must stay in sync with the store regardless -- including in a search narrow (where containsMessage is null) or when the list hasn't yet reached the newest message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent dde1400 commit 4f22b91

2 files changed

Lines changed: 151 additions & 1 deletion

File tree

lib/model/message_list.dart

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,18 @@ mixin _MessageSequence {
282282
_processMessage(messages.length - 1);
283283
}
284284

285+
/// Replace the message at [index] with [message],
286+
/// which must have the same message ID,
287+
/// and update [contents] accordingly.
288+
///
289+
/// The caller is responsible for updating [items],
290+
/// for example by calling [_reprocessAll].
291+
void _replaceMessage(int index, Message message) {
292+
assert(messages[index].id == message.id);
293+
messages[index] = message;
294+
contents[index] = parseMessageContent(message);
295+
}
296+
285297
/// Removes all messages from the list that satisfy [test].
286298
///
287299
/// Returns true if any messages were removed, false otherwise.
@@ -1154,9 +1166,37 @@ class MessageListView with ChangeNotifier, _MessageSequence {
11541166
}
11551167
}
11561168

1157-
/// Add [MessageEvent.message] to this view, if it belongs here.
1169+
/// Add [MessageEvent.message] to this view if it belongs here,
1170+
/// or adopt the event's copy if we already have the message.
11581171
void handleMessageEvent(MessageEvent event) {
11591172
final message = event.message;
1173+
1174+
final index = _findMessageWithId(message.id);
1175+
if (index != -1) {
1176+
// We already have the message, from a fetch whose response was
1177+
// computed after the message was sent:
1178+
// https://github.com/zulip/zulip-flutter/issues/929
1179+
// Instead of adding a duplicate, adopt the event's copy of the message,
1180+
// like the message store has (see [MessageStoreImpl.handleMessageEvent]),
1181+
// so that this view continues to see updates
1182+
// that are applied to the store's copy.
1183+
//
1184+
// This runs regardless of [narrow] and [haveNewest]:
1185+
// those decide whether a *new* message belongs in this view,
1186+
// but this message is already here and must stay in sync with the store.
1187+
_replaceMessage(index, message);
1188+
// If we sent this message, drop its outbox counterpart.
1189+
// (When [haveNewest] is false there are no [outboxMessages], so this
1190+
// is a no-op; see [_syncOutboxMessagesFromStore].)
1191+
_removeOutboxMessageOfEvent(event);
1192+
// This is a rare race, so the cost of rebuilding [items] is fine.
1193+
// [_reprocessAll] also rebuilds the outbox items, so there's no need
1194+
// to reprocess them separately.
1195+
_reprocessAll();
1196+
notifyListeners();
1197+
return;
1198+
}
1199+
11601200
if (narrow.containsMessage(message) != true || !_messageVisible(message)) {
11611201
assert(event.localMessageId == null || outboxMessages.none((message) =>
11621202
message.localMessageId == int.parse(event.localMessageId!, radix: 10)));

test/model/message_list_test.dart

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,116 @@ void main() {
880880
async.elapse(kLocalEchoDebounceDuration);
881881
checkNotNotified();
882882
}));
883+
884+
group('message already in list from fetch/event race', () {
885+
// Regression tests for: https://github.com/zulip/zulip-flutter/issues/929
886+
887+
test('no duplicate; adopt event copy of message', () async {
888+
final stream = eg.stream();
889+
await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream);
890+
final messages = List.generate(30, (i) => eg.streamMessage(stream: stream));
891+
final message = eg.streamMessage(stream: stream);
892+
await prepareMessages(foundOldest: true, messages: [...messages, message]);
893+
894+
check(model).messages.length.equals(31);
895+
await store.handleEvent(eg.messageEvent(message));
896+
checkNotifiedOnce();
897+
check(model).messages.length.equals(31);
898+
check(model.messages.last).identicalTo(message);
899+
});
900+
901+
test('event copy of message has different content', () async {
902+
// The fetched copy can be newer than the event's copy,
903+
// as when the message was edited just after being sent;
904+
// the event queue will separately deliver an update event.
905+
final stream = eg.stream();
906+
await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream);
907+
final messages = List.generate(30, (i) => eg.streamMessage(stream: stream));
908+
final message = eg.streamMessage(stream: stream, content: '<p>edited</p>');
909+
await prepareMessages(foundOldest: true, messages: [...messages, message]);
910+
911+
final eventMessage = Message.fromJson(
912+
message.toJson()..['content'] = '<p>original</p>');
913+
await store.handleEvent(eg.messageEvent(eventMessage));
914+
checkNotifiedOnce();
915+
check(model).messages.length.equals(31);
916+
check(model.messages.last).identicalTo(eventMessage);
917+
check(model.contents.last).isA<ZulipContent>()
918+
.equalsNode(parseContent('<p>original</p>'));
919+
});
920+
921+
test('with corresponding outbox message', () => awaitFakeAsync((async) async {
922+
final stream = eg.stream();
923+
await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream);
924+
await prepareOutboxMessages(count: 1, stream: stream);
925+
final localMessageId = store.outboxMessages.keys.single;
926+
async.elapse(kLocalEchoDebounceDuration);
927+
checkNotNotified();
928+
929+
final messages = List.generate(30, (i) => eg.streamMessage(stream: stream));
930+
final message = eg.streamMessage(stream: stream);
931+
await prepareMessages(foundOldest: true, messages: [...messages, message]);
932+
check(model)
933+
..messages.length.equals(31)
934+
..outboxMessages.single.localMessageId.equals(localMessageId);
935+
936+
await store.handleEvent(eg.messageEvent(message,
937+
localMessageId: localMessageId));
938+
checkNotifiedOnce();
939+
check(model)
940+
..messages.length.equals(31)
941+
..outboxMessages.isEmpty();
942+
}));
943+
944+
test('adopt even when narrow.containsMessage is null (e.g. search)', () async {
945+
// A narrow like [KeywordSearchNarrow] can't say whether a message
946+
// belongs (containsMessage returns null), but a message the view
947+
// already has must still adopt the event's copy.
948+
final stream = eg.stream();
949+
await prepare(narrow: KeywordSearchNarrow('hello'), stream: stream);
950+
final messages = List.generate(30, (i) => eg.streamMessage(stream: stream));
951+
final message = eg.streamMessage(stream: stream, content: '<p>edited</p>');
952+
await prepareMessages(foundOldest: true,
953+
messages: [...messages, message]);
954+
check(model).haveNewest.isTrue();
955+
956+
final eventMessage = Message.fromJson(
957+
message.toJson()..['content'] = '<p>original</p>');
958+
await store.handleEvent(eg.messageEvent(eventMessage));
959+
checkNotifiedOnce();
960+
check(model).messages.length.equals(31);
961+
check(model.messages.last).identicalTo(eventMessage);
962+
check(model.contents.last).isA<ZulipContent>()
963+
.equalsNode(parseContent('<p>original</p>'));
964+
});
965+
966+
test('adopt even when mid-history (!haveNewest)', () async {
967+
// The store clobbers its own copy on every message event, so a view must
968+
// adopt any message it already holds -- even when it isn't caught up to
969+
// the newest message.
970+
final stream = eg.stream();
971+
await prepare(narrow: ChannelNarrow(stream.streamId), stream: stream,
972+
anchor: NumericAnchor(1000));
973+
final message = eg.streamMessage(id: 1029, stream: stream,
974+
content: '<p>edited</p>');
975+
final messages = [
976+
for (int i = 0; i < 29; i++) eg.streamMessage(id: 1000 + i, stream: stream),
977+
message,
978+
];
979+
await prepareMessages(foundOldest: true, foundNewest: false,
980+
messages: messages);
981+
check(model).haveNewest.isFalse();
982+
983+
final eventMessage = Message.fromJson(
984+
message.toJson()..['content'] = '<p>original</p>');
985+
await store.handleEvent(eg.messageEvent(eventMessage));
986+
checkNotifiedOnce();
987+
check(model).messages.length.equals(30);
988+
check(model.messages.last).identicalTo(eventMessage);
989+
check(model.contents.last).isA<ZulipContent>()
990+
.equalsNode(parseContent('<p>original</p>'));
991+
});
992+
});
883993
});
884994

885995
group('addOutboxMessage', () {

0 commit comments

Comments
 (0)