Skip to content

Commit 8b7b890

Browse files
xsahil03xclaude
andauthored
fix(core): truncate channel state before reloading latest messages (#2690)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 508c019 commit 8b7b890

3 files changed

Lines changed: 140 additions & 2 deletions

File tree

packages/stream_chat_flutter_core/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## Upcoming
2+
3+
🐞 Fixed
4+
5+
- Fixed `StreamChannel.reloadChannel` merging the latest page on top of the previously loaded
6+
window instead of replacing it. The reload now matches a fresh open of the channel.
7+
18
## 9.24.0
29

310
✅ Added

packages/stream_chat_flutter_core/lib/src/stream_channel.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -776,8 +776,11 @@ class StreamChannelState extends State<StreamChannel> {
776776
});
777777
}
778778

779-
/// Reloads the channel with latest message
780-
Future<void> reloadChannel() => _queryAtMessage();
779+
/// Reloads the channel with latest messages, replacing the loaded window.
780+
Future<void> reloadChannel() {
781+
channel.state?.truncate();
782+
return _queryAtMessage();
783+
}
781784

782785
Future<void> _maybeInitChannel() async {
783786
// If the channel doesn't have an CID yet, it hasn't been created on the

packages/stream_chat_flutter_core/test/stream_channel_test.dart

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,4 +908,132 @@ void main() {
908908
},
909909
);
910910
});
911+
912+
group('reloadChannel', () {
913+
final mockChannel = MockChannel();
914+
tearDownAll(mockChannel.dispose);
915+
916+
// Mutable backing so the mocked `truncate` and `query` can model the
917+
// real `ChannelClientState.messages` lifecycle: truncate clears it,
918+
// a `query` response is merged on top of whatever's there. With the
919+
// truncate-before-query fix the merge target is empty; without it,
920+
// the previously loaded window leaks past the reload (the original bug).
921+
var stateMessages = <Message>[];
922+
923+
List<Message> _generateMessages(int count, {required String prefix}) =>
924+
List.generate(
925+
count,
926+
(i) => Message(
927+
id: '$prefix-$i',
928+
createdAt: DateTime(2024).add(Duration(seconds: i)),
929+
user: User(id: 'otherUserId'),
930+
),
931+
);
932+
933+
setUp(() {
934+
when(() => mockChannel.cid).thenReturn('test:channel');
935+
when(() => mockChannel.state.messages).thenAnswer((_) => stateMessages);
936+
when(() => mockChannel.state.isUpToDate).thenReturn(true);
937+
when(() => mockChannel.state.unreadCount).thenReturn(0);
938+
when(() => mockChannel.state.truncate()).thenAnswer((_) {
939+
stateMessages = [];
940+
});
941+
when(
942+
() => mockChannel.query(
943+
preferOffline: any(named: 'preferOffline'),
944+
messagesPagination: any(named: 'messagesPagination'),
945+
),
946+
).thenAnswer((_) async {
947+
// Model `Channel.query` merging the new page onto the existing
948+
// window — dedupe by id is unnecessary here because the test
949+
// primes disjoint old/new sets.
950+
final newMessages = _generateMessages(30, prefix: 'new');
951+
stateMessages = [...stateMessages, ...newMessages];
952+
return ChannelState(messages: newMessages);
953+
});
954+
});
955+
956+
tearDown(() {
957+
stateMessages = <Message>[];
958+
reset(mockChannel);
959+
});
960+
961+
Future<StreamChannelState> _pumpStreamChannel(WidgetTester tester) async {
962+
StreamChannelState? channelState;
963+
await tester.pumpWidget(
964+
MaterialApp(
965+
home: Scaffold(
966+
body: StreamChannel(
967+
channel: mockChannel,
968+
child: Builder(
969+
builder: (context) {
970+
channelState = StreamChannel.of(context);
971+
return const Text('Channel Content');
972+
},
973+
),
974+
),
975+
),
976+
),
977+
);
978+
await tester.pumpAndSettle();
979+
return channelState!;
980+
}
981+
982+
testWidgets(
983+
'truncates the existing window before querying the latest messages',
984+
(tester) async {
985+
final streamChannel = await _pumpStreamChannel(tester);
986+
987+
await streamChannel.reloadChannel();
988+
989+
verifyInOrder([
990+
() => mockChannel.state.truncate(),
991+
() => mockChannel.query(
992+
preferOffline: any(named: 'preferOffline'),
993+
messagesPagination: any(named: 'messagesPagination'),
994+
),
995+
]);
996+
},
997+
);
998+
999+
testWidgets(
1000+
'queries with no around-anchor (loads the latest page)',
1001+
(tester) async {
1002+
final streamChannel = await _pumpStreamChannel(tester);
1003+
1004+
await streamChannel.reloadChannel();
1005+
1006+
final captured = verify(
1007+
() => mockChannel.query(
1008+
preferOffline: any(named: 'preferOffline'),
1009+
messagesPagination: captureAny(named: 'messagesPagination'),
1010+
),
1011+
).captured.single as PaginationParams;
1012+
1013+
expect(captured.idAround, isNull);
1014+
expect(captured.createdAtAround, isNull);
1015+
},
1016+
);
1017+
1018+
testWidgets(
1019+
'state contains only the latest page after reloading (drops the '
1020+
'previously loaded window)',
1021+
(tester) async {
1022+
// Seed the around-Y window that a prior `loadChannelAtMessage`
1023+
// would have produced.
1024+
stateMessages = _generateMessages(30, prefix: 'old');
1025+
1026+
final streamChannel = await _pumpStreamChannel(tester);
1027+
await streamChannel.reloadChannel();
1028+
1029+
// Without the truncate the merge would land us on 60 messages
1030+
// (old 30 + new 30). The fix yields just the latest page.
1031+
expect(stateMessages, hasLength(30));
1032+
expect(
1033+
stateMessages.map((m) => m.id),
1034+
everyElement(startsWith('new-')),
1035+
);
1036+
},
1037+
);
1038+
});
9111039
}

0 commit comments

Comments
 (0)