Skip to content
1 change: 1 addition & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

- Fixed `StreamChatClient.sync` letting a failure from its final `updateLastSyncAt` write escape to the caller; it is now handled by the method's own error handling, like every other sync failure.
- Fixed `ChannelClientState` no longer handling `notification.mark_read` (regression since 9.20.0), which left `unreadCount` stale after `Channel.markRead` on channels the user isn't watching.
- Fixed `Channel.getReplies` adding the parent message to `ChannelClientState.threads` when a backend returns it alongside the replies, which rendered the thread root twice. The online path now filters it out, matching the offline one.

## 9.27.0

Expand Down
6 changes: 4 additions & 2 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3480,15 +3480,17 @@ class ChannelClientState {
List<Message> messages, {
bool upsert = true,
}) {
var messagesToMerge = messages;
// The parent is rendered in its own slot, so keep it out of the reply list
// even when a backend returns it alongside the replies.
var messagesToMerge = messages.where((it) => it.id != parentId).toList();
if (!upsert) {
final existingThread = threads[parentId];
// Don't create a phantom entry for a thread that was never paged in,
// and only update replies already loaded in it.
if (existingThread == null) return;
final existingIds = {for (final m in existingThread) m.id};
messagesToMerge =
messages.where((m) => existingIds.contains(m.id)).toList();
messagesToMerge.where((m) => existingIds.contains(m.id)).toList();
if (messagesToMerge.isEmpty) return;
}

Expand Down
26 changes: 26 additions & 0 deletions packages/stream_chat/test/src/client/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3112,6 +3112,32 @@ void main() {
verify(() => client.getReplies(parentId)).called(1);
});

test('`.getReplies` keeps the parent message out of the thread', () async {
const parentId = 'test-parent-id';

// Some backends return the parent as the first message of the oldest
// page. It is rendered from its own copy, so it must not also become a
// reply — otherwise the thread shows its root twice.
final messages = [
Message(id: parentId),
...List.generate(
3,
(index) => Message(id: 'test-message-id-$index', parentId: parentId),
),
];

when(() => client.getReplies(parentId)).thenAnswer(
(_) async => QueryRepliesResponse()..messages = messages,
);

await channel.getReplies(parentId);

final threadMessages = channel.state!.threads[parentId];
expect(threadMessages, isNotNull);
expect(threadMessages!.length, messages.length - 1);
expect(threadMessages.any((it) => it.id == parentId), isFalse);
});

test('`.getReactions`', () async {
const messageId = 'test-message-id';

Expand Down
6 changes: 6 additions & 0 deletions packages/stream_chat_flutter_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Upcoming

🐞 Fixed

- Fixed `StreamChannelListController` crashing with a null-check error when its local sort ran over a channel disposed mid-query (e.g. a client disconnect/logout racing an in-flight `loadMore`); such channels are now sorted last instead.

## 9.27.0

✅ Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,17 @@ class StreamChannelListController extends PagedValueNotifier<int, Channel> {
orElse: () => newValue,
(success) => success.copyWith(
items: success.items.sortedByCompare(
(it) => it.state!.channelState,
channelSort.compare,
// A channel loses its state when it is disposed — e.g. a client
// disconnect/logout or a channel-removal event racing an
// in-flight query — so sort stateless channels last instead of
// null-asserting on them.
(it) => it.state?.channelState,
(a, b) => switch ((a, b)) {
(null, null) => 0,
(null, _) => 1,
(_, null) => -1,
(final a?, final b?) => channelSort.compare(a, b),
},
),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,41 @@ void main() {
},
);

test(
'local sort places channels with disposed state last instead of crashing',
() {
ChannelState channelStateFor({required DateTime createdAt}) => ChannelState(
channel: ChannelModel(
cid: 'messaging:${createdAt.millisecondsSinceEpoch}',
createdAt: createdAt,
),
);

final older = MockChannel();
when(() => older.state.channelState)
.thenReturn(channelStateFor(createdAt: DateTime(2026, 1, 1)));

final newer = MockChannel();
when(() => newer.state.channelState)
.thenReturn(channelStateFor(createdAt: DateTime(2026, 6, 1)));

// A channel disposed while a query is in flight (client disconnect or
// logout, a channel-removal event) has its state nulled out.
final disposed = NonInitializedMockChannel();

final controller = StreamChannelListController(
client: client,
channelStateSort: defaultChannelListSort,
);

expect(
() => controller.value =
PagedValue<int, Channel>(items: [disposed, older, newer]),
returnsNormally,
);
expect(controller.value.asSuccess.items, equals([newer, older, disposed]));
});

group('Event handling', () {
late StreamController<Event> eventController;

Expand Down
Loading