Skip to content

Commit 7946a52

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 7946a52

7 files changed

Lines changed: 91 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: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,30 @@ class StreamChannel extends StatefulWidget {
4545
this.initialMessageId,
4646
this.errorBuilder = _defaultErrorBuilder,
4747
this.loadingBuilder = _defaultLoadingBuilder,
48-
});
48+
}) : _shouldPosition = true;
49+
50+
/// Exposes a [channel] to descendants without repositioning the loaded
51+
/// 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+
/// See also:
60+
///
61+
/// * [StreamChannel.new], which initializes the channel and positions it
62+
/// on mount.
63+
const StreamChannel.value({
64+
super.key,
65+
required this.child,
66+
required this.channel,
67+
}) : showLoading = false,
68+
initialMessageId = null,
69+
errorBuilder = _defaultErrorBuilder,
70+
loadingBuilder = _defaultLoadingBuilder,
71+
_shouldPosition = false;
4972

5073
/// The child of the widget
5174
final Widget child;
@@ -65,6 +88,10 @@ class StreamChannel extends StatefulWidget {
6588
/// Widget builder used in case an error occurs while building the channel.
6689
final ErrorWidgetBuilder errorBuilder;
6790

91+
// Whether to position the loaded window on mount (initialMessageId,
92+
// last-read, or latest). Only false for StreamChannel.value.
93+
final bool _shouldPosition;
94+
6895
static Widget _defaultLoadingBuilder(BuildContext context) {
6996
final backgroundColor = _getDefaultBackgroundColor(context);
7097
return Material(
@@ -675,16 +702,43 @@ class StreamChannelState extends State<StreamChannel> {
675702
);
676703
}
677704

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

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

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

0 commit comments

Comments
 (0)