From d2e79a3bdc5a5ead5cf38bd06602bebd6dc4e684 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 19 Jun 2026 17:56:19 +0200 Subject: [PATCH 1/2] fix(ui): preserve last-message preview during channel-state reloads --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../stream_channel_list_item.dart | 14 ++ .../stream_channel_list_item_test.dart | 192 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index b8e76a472..42af8cbf6 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,6 +3,7 @@ 🐛 Fixed - Fixed a use-after-dispose race condition in `StreamAttachmentPickerController`, `StreamAudioRecorderController`, and `StreamAudioPlaylistController`: async methods could write `value` after `dispose()`, causing a `notifyListeners()` assertion throw in debug mode. All three now use the `DisposeAwareValueNotifier` mixin from `stream_chat_flutter_core`. +- Fixed last-message preview flicker during channel-state reloads. ✅ Added diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart index baba31acf..4613747d0 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -624,6 +624,13 @@ class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithSt // Find the last valid message. final message = messages.lastWhereOrNull(_defaultLastMessagePredicate); + // Cache only while the channel has the latest messages loaded + // (isUpToDate). When isUpToDate is false (e.g. Channel.query(idAround:) + // truncates state mid-load), the previous value keeps rendering instead + // of false rendering the empty state. + if (channelState.isUpToDate) { + _currentLastMessage = message; + } final latestLastMessage = [message, _currentLastMessage].latest; if (latestLastMessage == null) { @@ -736,6 +743,13 @@ class _ChannelLastMessageTextState extends State { // Otherwise, show the channel last message if it exists. final message = messages.lastWhereOrNull(widget.lastMessagePredicate); + // Cache only while the channel has the latest messages loaded + // (isUpToDate). When isUpToDate is false (e.g. Channel.query(idAround:) + // truncates state mid-load), the previous value keeps rendering instead + // of false rendering the empty state. + if (channelState.isUpToDate) { + _currentLastMessage = message; + } final latestLastMessage = [message, _currentLastMessage].latest; if (latestLastMessage == null) { diff --git a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart new file mode 100644 index 000000000..102d90202 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart @@ -0,0 +1,192 @@ +// Tests for the channel-list preview widget that derives the last-message +// text from the channel state. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../../mocks.dart'; + +void main() { + // The default English translation; matches `context.translations.emptyMessagesText`. + const emptyText = 'No messages yet'; + + late MockClient client; + late MockClientState clientState; + late OwnUser currentUser; + + late MockChannel channelA; + late MockChannelState stateA; + late StreamController> messagesA; + late bool isUpToDateA; + + late MockChannel channelB; + late MockChannelState stateB; + late StreamController> messagesB; + late bool isUpToDateB; + + void wireChannel({ + required MockChannel channel, + required MockChannelState state, + required StreamController> controller, + required bool Function() isUpToDate, + required String cid, + }) { + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(state); + + when(() => state.messagesStream).thenAnswer((_) => controller.stream); + when(() => state.messages).thenReturn([]); + when(() => state.draft).thenReturn(null); + when(() => state.draftStream).thenAnswer((_) => Stream.value(null)); + when(() => state.read).thenReturn(const []); + when(() => state.readStream).thenAnswer((_) => const Stream.empty()); + when(() => state.typingEvents).thenReturn(const {}); + when(() => state.typingEventsStream).thenAnswer((_) => Stream.value(const {})); + when(() => state.unreadCount).thenReturn(0); + when(() => state.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => state.isUpToDate).thenAnswer((_) => isUpToDate()); + when(() => state.channelState).thenReturn( + ChannelState(channel: ChannelModel(cid: cid)), + ); + } + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + currentUser = OwnUser(id: 'me'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + + isUpToDateA = true; + channelA = MockChannel(id: 'a', type: 'messaging'); + stateA = MockChannelState(); + messagesA = StreamController>.broadcast(); + addTearDown(messagesA.close); + wireChannel( + channel: channelA, + state: stateA, + controller: messagesA, + isUpToDate: () => isUpToDateA, + cid: 'messaging:a', + ); + + isUpToDateB = true; + channelB = MockChannel(id: 'b', type: 'messaging'); + stateB = MockChannelState(); + messagesB = StreamController>.broadcast(); + addTearDown(messagesB.close); + wireChannel( + channel: channelB, + state: stateB, + controller: messagesB, + isUpToDate: () => isUpToDateB, + cid: 'messaging:b', + ); + }); + + Future pumpSubtitle(WidgetTester tester, Channel channel) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChat( + client: client, + themeData: StreamChatThemeData(), + child: ChannelListTileSubtitle(channel: channel), + ), + ), + ), + ); + } + + testWidgets( + 'preserves the last-known message when state is not up-to-date and emits empty', + (tester) async { + final other = User(id: 'other'); + final msg1 = Message(id: 'm1', text: 'hello', user: other); + + when(() => stateA.messages).thenReturn([msg1]); + await pumpSubtitle(tester, channelA); + messagesA.add([msg1]); + await tester.pumpAndSettle(); + + expect(find.text('hello'), findsOneWidget); + expect(find.text(emptyText), findsNothing); + + // While loading a historical window, isUpToDate flips false and the + // intermediate state emits an empty messages list. + isUpToDateA = false; + when(() => stateA.messages).thenReturn([]); + messagesA.add([]); + await tester.pumpAndSettle(); + + expect(find.text(emptyText), findsNothing); + expect(find.text('hello'), findsOneWidget); + + // The loaded window arrives; isUpToDate restored. + final msg2 = Message(id: 'm2', text: 'world', user: other); + when(() => stateA.messages).thenReturn([msg1, msg2]); + messagesA.add([msg1, msg2]); + isUpToDateA = true; + await tester.pumpAndSettle(); + + expect(find.text('world'), findsOneWidget); + expect(find.text(emptyText), findsNothing); + }, + ); + + testWidgets( + "rebinding the widget to a different channel shows that channel's state, not the previous one's", + (tester) async { + // List reordering reuses the underlying State across channels (no key + // on the list item). Channel B is empty; channel A had a message — + // the cell must render B's empty state, not A's last message. + final other = User(id: 'other'); + final msgA = Message(id: 'ma', text: 'channel-a-message', user: other); + + when(() => stateA.messages).thenReturn([msgA]); + await pumpSubtitle(tester, channelA); + messagesA.add([msgA]); + await tester.pumpAndSettle(); + + expect(find.text('channel-a-message'), findsOneWidget); + + when(() => stateB.messages).thenReturn([]); + await pumpSubtitle(tester, channelB); + messagesB.add([]); + await tester.pumpAndSettle(); + + expect(find.text('channel-a-message'), findsNothing); + expect(find.text(emptyText), findsOneWidget); + }, + ); + + testWidgets( + 'shows the empty-state when the channel is truncated while up-to-date', + (tester) async { + // A channel.truncated event clears messages but leaves isUpToDate true. + // The preview must reflect the now-empty channel. + final other = User(id: 'other'); + final msg1 = Message(id: 'm1', text: 'hello', user: other); + + when(() => stateA.messages).thenReturn([msg1]); + await pumpSubtitle(tester, channelA); + messagesA.add([msg1]); + await tester.pumpAndSettle(); + + expect(find.text('hello'), findsOneWidget); + + when(() => stateA.messages).thenReturn([]); + messagesA.add([]); + await tester.pumpAndSettle(); + + expect(find.text('hello'), findsNothing); + expect(find.text(emptyText), findsOneWidget); + }, + ); +} From 08e0abf20a30cc39148aeca62a6d67ef8fd4839e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 19 Jun 2026 20:36:34 +0200 Subject: [PATCH 2/2] fix(ui): preserve last-message preview during channel-state reloads --- .../stream_channel_list_item.dart | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart index 4613747d0..6d62de3f1 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -624,14 +624,18 @@ class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithSt // Find the last valid message. final message = messages.lastWhereOrNull(_defaultLastMessagePredicate); - // Cache only while the channel has the latest messages loaded - // (isUpToDate). When isUpToDate is false (e.g. Channel.query(idAround:) - // truncates state mid-load), the previous value keeps rendering instead - // of false rendering the empty state. + // `_currentLastMessage` holds the most recent message seen while the + // channel has the latest messages (isUpToDate). + // While isUpToDate is false (e.g. Channel.query(idAround:) truncates + // state mid-load), fall back to it so the preview shows the actual + // latest message. + final Message? latestLastMessage; if (channelState.isUpToDate) { - _currentLastMessage = message; + latestLastMessage = message; + _currentLastMessage = latestLastMessage; + } else { + latestLastMessage = [message, _currentLastMessage].latest; } - final latestLastMessage = [message, _currentLastMessage].latest; if (latestLastMessage == null) { return Text( @@ -743,14 +747,18 @@ class _ChannelLastMessageTextState extends State { // Otherwise, show the channel last message if it exists. final message = messages.lastWhereOrNull(widget.lastMessagePredicate); - // Cache only while the channel has the latest messages loaded - // (isUpToDate). When isUpToDate is false (e.g. Channel.query(idAround:) - // truncates state mid-load), the previous value keeps rendering instead - // of false rendering the empty state. + // `_currentLastMessage` holds the most recent message seen while the + // channel has the latest messages (isUpToDate). + // While isUpToDate is false (e.g. Channel.query(idAround:) truncates + // state mid-load), fall back to it so the preview shows the actual + // latest message. + final Message? latestLastMessage; if (channelState.isUpToDate) { - _currentLastMessage = message; + latestLastMessage = message; + _currentLastMessage = latestLastMessage; + } else { + latestLastMessage = [message, _currentLastMessage].latest; } - final latestLastMessage = [message, _currentLastMessage].latest; if (latestLastMessage == null) { return Text(