Skip to content

Commit 628f661

Browse files
xsahil03xclaude
andcommitted
fix(llc, core, ui): isolate channel state from sub-route wraps and smooth thread-open scroll
A small cluster of related issues that surfaced together while investigating thread-page UX. All four are independent bugs touching the LLC, core, and the vendored SPL. - `Channel.getMessagesById` was merging fetched messages into `ChannelState.messages` as a side effect. Resolving a thread parent for an in-channel reply would silently inject the parent into the loaded channel window, typically at index 0. The fetch is now pure — callers that want to refresh the loaded window can pipe the response through `ChannelClientState.updateMessage`. - `StreamChannel.getMessage` previously hit the network for any message not in `channel.state.messages`, ignoring thread replies and pinned messages. Cache lookup now also scans `channel.state.threads.values` and `channel.state.pinnedMessages`. - Added `StreamChannel.value` constructor for wrapping an already-initialized channel purely to expose it via `StreamChannel.of` to a sub-route or overlay (thread page, channel info screen, long-press modal, attachment viewer). Skips the channel-page positioning the default constructor runs, which would otherwise overwrite the parent route's loaded window. - `MessageListCore` no longer reloads the parent channel from its dispose path when the disposing instance is in thread mode — a thread's lifecycle shouldn't be touching the channel's loaded window. - `MessageListCore` no longer refetches thread replies on first attach when `state.threads[parentId]` is already populated. WebSocket events keep that cache live; the redundant fetch caused a merge-rebuild flicker on warm cache. - `ScrollablePositionedList._startScroll` waits one frame for `itemPositions` to be published when it's empty. Previously a `scrollTo` requested from a post-frame callback right after mount would fall through to the dual-controller teleport path even for nearby targets, fake-scrolling two screens for what should have been a short animation. Matches the Compose `LazyListState` pattern of suspending until first composition before scrolling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 58331ec commit 628f661

7 files changed

Lines changed: 94 additions & 13 deletions

File tree

packages/stream_chat/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
🐞 Fixed
1010

11+
- Fixed `Channel.getMessagesById` mutating the loaded channel window as a side effect of fetching.
1112
- Fixed `StreamChatClient.queryDrafts` not forwarding the `filter` argument to the API.
1213

1314
- Coalesced concurrent `queryChannels` calls with identical parameters via an in-flight cache.

packages/stream_chat/lib/src/client/channel.dart

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,10 +1801,7 @@ class Channel {
18011801
List<String> messageIDs,
18021802
) async {
18031803
_checkInitialized();
1804-
final res = await _client.getMessagesById(id!, type, messageIDs);
1805-
final messages = res.messages;
1806-
state!.updateChannelState(state!.channelState.copyWith(messages: messages));
1807-
return res;
1804+
return _client.getMessagesById(id!, type, messageIDs);
18081805
}
18091806

18101807
/// Translate a message by given [messageId] and [language].

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
🐞 Fixed
44

5+
- Fixed `ScrollablePositionedList.scrollTo` taking the long-distance teleport path when called
6+
immediately after mount (before `itemPositions` had been published). It now waits one frame
7+
for layout, then animates the real pixel distance.
58
- Fixed `StreamMessageListView` not auto-scrolling to the bottom on the user's own outgoing message
69
until the server confirmed it.
710
- Fixed `StreamMessageListView` tripping `A RenderViewport exceeded its maximum number of layout

packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,16 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
750750
Curve curve = Curves.linear,
751751
required List<double> opacityAnimationWeights,
752752
}) async {
753+
// If `itemPositions` hasn't been published yet (e.g. a scroll requested
754+
// from a post-frame callback right after mount), the in-viewport branch
755+
// below would miss the target and fall through to the dual-controller
756+
// teleport path — even when the target is actually a few items away.
757+
// Wait one frame so layout can report positions, then proceed.
758+
if (primary.itemPositionsNotifier.itemPositions.value.isEmpty) {
759+
await SchedulerBinding.instance.endOfFrame;
760+
if (!mounted) return;
761+
}
762+
753763
final direction = index > primary.target ? 1 : -1;
754764
final itemPosition =
755765
primary.itemPositionsNotifier.itemPositions.value.firstWhereOrNull(

packages/stream_chat_flutter_core/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
## Upcoming
22

3+
✅ Added
4+
5+
- Added `StreamChannel.value` — exposes an already-initialized channel without running channel-page
6+
positioning. Use it for sub-route and overlay wraps.
7+
38
🐞 Fixed
49

10+
- Fixed `StreamChannel.getMessage` hitting the network for thread replies and pinned messages
11+
already in local state.
12+
- Fixed `MessageListCore` reloading the parent channel from its dispose path when running in
13+
thread mode.
514
- Fixed `StreamChannel.reloadChannel` merging the latest page on top of the previously loaded
615
window instead of replacing it. The reload now matches a fresh open of the channel.
716

packages/stream_chat_flutter_core/lib/src/message_list_core.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ class MessageListCoreState extends State<MessageListCore> {
305305
@override
306306
void dispose() {
307307
_teardownController(widget.messageListController);
308-
_reloadChannelIfNeeded();
308+
if (!_isThreadConversation) _reloadChannelIfNeeded();
309309
super.dispose();
310310
}
311311
}

packages/stream_chat_flutter_core/lib/src/stream_channel.dart

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,33 @@ class StreamChannel extends StatefulWidget {
4545
this.initialMessageId,
4646
this.errorBuilder = _defaultErrorBuilder,
4747
this.loadingBuilder = _defaultLoadingBuilder,
48-
});
48+
}) : _shouldPosition = true;
49+
50+
/// Exposes an already-initialized [channel] to descendants without
51+
/// repositioning the loaded window on mount.
52+
///
53+
/// Use this when wrapping a channel in a sub-route or overlay for context
54+
/// access only — e.g. a thread page, channel info screen, long-press
55+
/// modal, or attachment viewer. The default constructor would otherwise
56+
/// re-run channel-page positioning and overwrite the parent route's
57+
/// loaded window.
58+
///
59+
/// The [channel] must already be initialized via [Channel.watch];
60+
/// descendants depend on `channel.state` being non-null.
61+
///
62+
/// See also:
63+
///
64+
/// * [StreamChannel.new], which initializes the channel and positions it
65+
/// on mount.
66+
const StreamChannel.value({
67+
super.key,
68+
required this.child,
69+
required this.channel,
70+
}) : showLoading = false,
71+
initialMessageId = null,
72+
errorBuilder = _defaultErrorBuilder,
73+
loadingBuilder = _defaultLoadingBuilder,
74+
_shouldPosition = false;
4975

5076
/// The child of the widget
5177
final Widget child;
@@ -65,6 +91,10 @@ class StreamChannel extends StatefulWidget {
6591
/// Widget builder used in case an error occurs while building the channel.
6692
final ErrorWidgetBuilder errorBuilder;
6793

94+
// Whether to position the loaded window on mount (initialMessageId,
95+
// last-read, or latest). Only false for StreamChannel.value.
96+
final bool _shouldPosition;
97+
6898
static Widget _defaultLoadingBuilder(BuildContext context) {
6999
final backgroundColor = _getDefaultBackgroundColor(context);
70100
return Material(
@@ -675,16 +705,43 @@ class StreamChannelState extends State<StreamChannel> {
675705
);
676706
}
677707

678-
///
708+
/// Returns the message with the given [messageId].
679709
Future<Message> getMessage(String messageId) async {
680-
var message = channel.state?.messages.firstWhereOrNull(
681-
(it) => it.id == messageId,
710+
if (_findCachedMessage(messageId) case final cached?) return cached;
711+
712+
final response = await channel.getMessagesById([messageId]);
713+
return response.messages.firstWhere(
714+
(m) => m.id == messageId,
715+
orElse: () => throw StateError('Message "$messageId" not found'),
682716
);
683-
if (message == null) {
684-
final response = await channel.getMessagesById([messageId]);
685-
message = response.messages.first;
717+
}
718+
719+
// Scans cached locations in decreasing hit-likelihood, returning on the
720+
// first match. Plain for-loops over `firstWhereOrNull`/`expand` to avoid
721+
// per-call closure and iterable allocations.
722+
Message? _findCachedMessage(String messageId) {
723+
final state = channel.state;
724+
if (state == null) return null;
725+
726+
// Hot path: regular channel messages in the loaded window.
727+
for (final message in state.messages) {
728+
if (message.id == messageId) return message;
686729
}
687-
return message;
730+
731+
// Thread replies — only helps when `messageId` is itself a reply; thread
732+
// parents live in `state.messages`, not under `state.threads`.
733+
for (final replies in state.threads.values) {
734+
for (final message in replies) {
735+
if (message.id == messageId) return message;
736+
}
737+
}
738+
739+
// Pinned messages can sit outside the loaded window, so check them last.
740+
for (final message in state.pinnedMessages) {
741+
if (message.id == messageId) return message;
742+
}
743+
744+
return null;
688745
}
689746

690747
/// Query channel members.
@@ -790,6 +847,10 @@ class StreamChannelState extends State<StreamChannel> {
790847
// Otherwise, we first initialize the channel if it's not yet initialized.
791848
if (channel.state == null) await channel.watch();
792849

850+
// If the widget was created using the StreamChannel.value constructor,
851+
// we skip positioning so the already-loaded channel state is kept intact.
852+
if (!widget._shouldPosition) return;
853+
793854
// First we try to load the channel at the initial message if
794855
// 'initialMessageId' is provided in the widget.
795856
if (widget.initialMessageId case final initialMessageId?) {

0 commit comments

Comments
 (0)