diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index f12a8a8c3..82d281ea5 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +🐞 Fixed + +- Fixed `StreamChannel.reloadChannel` merging the latest page on top of the previously loaded + window instead of replacing it. The reload now matches a fresh open of the channel. + ## 9.24.0 ✅ Added diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index c1d1e10ce..6f7b2498f 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -776,8 +776,11 @@ class StreamChannelState extends State { }); } - /// Reloads the channel with latest message - Future reloadChannel() => _queryAtMessage(); + /// Reloads the channel with latest messages, replacing the loaded window. + Future reloadChannel() { + channel.state?.truncate(); + return _queryAtMessage(); + } Future _maybeInitChannel() async { // If the channel doesn't have an CID yet, it hasn't been created on the diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index 393cacc5e..6e7fe7a1e 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -908,4 +908,132 @@ void main() { }, ); }); + + group('reloadChannel', () { + final mockChannel = MockChannel(); + tearDownAll(mockChannel.dispose); + + // Mutable backing so the mocked `truncate` and `query` can model the + // real `ChannelClientState.messages` lifecycle: truncate clears it, + // a `query` response is merged on top of whatever's there. With the + // truncate-before-query fix the merge target is empty; without it, + // the previously loaded window leaks past the reload (the original bug). + var stateMessages = []; + + List _generateMessages(int count, {required String prefix}) => + List.generate( + count, + (i) => Message( + id: '$prefix-$i', + createdAt: DateTime(2024).add(Duration(seconds: i)), + user: User(id: 'otherUserId'), + ), + ); + + setUp(() { + when(() => mockChannel.cid).thenReturn('test:channel'); + when(() => mockChannel.state.messages).thenAnswer((_) => stateMessages); + when(() => mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.state.unreadCount).thenReturn(0); + when(() => mockChannel.state.truncate()).thenAnswer((_) { + stateMessages = []; + }); + when( + () => mockChannel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: any(named: 'messagesPagination'), + ), + ).thenAnswer((_) async { + // Model `Channel.query` merging the new page onto the existing + // window — dedupe by id is unnecessary here because the test + // primes disjoint old/new sets. + final newMessages = _generateMessages(30, prefix: 'new'); + stateMessages = [...stateMessages, ...newMessages]; + return ChannelState(messages: newMessages); + }); + }); + + tearDown(() { + stateMessages = []; + reset(mockChannel); + }); + + Future _pumpStreamChannel(WidgetTester tester) async { + StreamChannelState? channelState; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChannel( + channel: mockChannel, + child: Builder( + builder: (context) { + channelState = StreamChannel.of(context); + return const Text('Channel Content'); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return channelState!; + } + + testWidgets( + 'truncates the existing window before querying the latest messages', + (tester) async { + final streamChannel = await _pumpStreamChannel(tester); + + await streamChannel.reloadChannel(); + + verifyInOrder([ + () => mockChannel.state.truncate(), + () => mockChannel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: any(named: 'messagesPagination'), + ), + ]); + }, + ); + + testWidgets( + 'queries with no around-anchor (loads the latest page)', + (tester) async { + final streamChannel = await _pumpStreamChannel(tester); + + await streamChannel.reloadChannel(); + + final captured = verify( + () => mockChannel.query( + preferOffline: any(named: 'preferOffline'), + messagesPagination: captureAny(named: 'messagesPagination'), + ), + ).captured.single as PaginationParams; + + expect(captured.idAround, isNull); + expect(captured.createdAtAround, isNull); + }, + ); + + testWidgets( + 'state contains only the latest page after reloading (drops the ' + 'previously loaded window)', + (tester) async { + // Seed the around-Y window that a prior `loadChannelAtMessage` + // would have produced. + stateMessages = _generateMessages(30, prefix: 'old'); + + final streamChannel = await _pumpStreamChannel(tester); + await streamChannel.reloadChannel(); + + // Without the truncate the merge would land us on 60 messages + // (old 30 + new 30). The fix yields just the latest page. + expect(stateMessages, hasLength(30)); + expect( + stateMessages.map((m) => m.id), + everyElement(startsWith('new-')), + ); + }, + ); + }); }