Skip to content

fix(ui): preserve last-message preview during channel-state reloads#2774

Merged
VelikovPetar merged 9 commits into
masterfrom
bug/FLU-549_preserve_last_message_preview_during_channel_state_reloads
Jun 30, 2026
Merged

fix(ui): preserve last-message preview during channel-state reloads#2774
VelikovPetar merged 9 commits into
masterfrom
bug/FLU-549_preserve_last_message_preview_during_channel_state_reloads

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Submit a pull request

Linear: FLU-547

CLA

  • I have signed the Stream CLA (required).
  • The code changes follow best practices
  • Code changes are tested (add some information if not applicable)

Description of the pull request

Summary

When the user opens a channel that has unread messages, the corresponding channel-list cell briefly renders the empty-state text ("No messages yet") before snapping back to the real last-message preview. This PR fixes the flicker and adds regression tests for the underlying state-emission scenarios.

The bug

StreamChannel._maybeInitChannel calls loadChannelAtMessage(lastReadMessageId)_queryAtMessageChannel.query(messagesPagination: PaginationParams(idAround: …)) when entering a channel with unreads (packages/stream_chat_flutter_core/lib/src/stream_channel.dart).

Inside Channel.query (packages/stream_chat/lib/src/client/channel.dart), the idAround path runs state.truncate() (which clears messages to [] and emits a new ChannelState) immediately followed by updateChannelState(...) (which emits the populated state). Both emissions go through _channelStateController (a BehaviorSubject) and reach listeners in separate microtasks — so every subscriber to messagesStream sees an empty list, then the populated list.

The channel-list cell's preview widget (_ChannelLastMessageWithStatus in stream_channel_list_item.dart, plus its public twin ChannelLastMessageText) was relying on a Message? _currentLastMessage cache field to ride out exactly this kind of transient empty emission via a .latest selector. The cache field was declared but never assigned, so the absorber was a no-op and the empty emission produced the visible flash.

The fix

Assign _currentLastMessage = message (including null) on every build, gated on channelState.isUpToDate. This is done before computing .latest, so the freshly-stored value participates in the selector.

The gate matters: _queryAtMessage flips isUpToDate = false before truncating, so the transient empty list is skipped by the cache. A real channel.truncated event (_listenChannelTruncated) clears messages while leaving isUpToDate = true, so the cache correctly clears to null and the empty-state surfaces.

Tests

New file: packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart. Three scenarios:

  • Preserves the last-known message when state is not up-to-date and emits empty.
  • Rebinding the widget to a different channel shows that channel's state, not the previous one's.
  • Shows the empty-state when the channel is truncated while up-to-date.

Manual verification

  • Open a channel with unread messages from the channel list → preview no longer flashes the empty-state.
  • Truncate a channel (admin action) → preview switches to "No messages yet".
  • Hard-delete the last message → preview switches to "No messages yet".

UI Changes

Before After
flash-before.mp4
flash-after.mp4

Summary by CodeRabbit

  • Bug Fixes
    • Resolved flickering of the last-message preview during channel-state reloads
    • Improved last-message preview accuracy when channel data isn’t up to date, including correct subtitle updates when rebinding and when showing empty-state text after truncation
  • Tests
    • Added widget coverage for subtitle behavior across up-to-date, rebinding, and truncation scenarios
  • Documentation
    • Updated the package changelog with the fix under Upcoming/Fixed

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: df8bca5e-b356-44c5-80de-54aae7be84d2

📥 Commits

Reviewing files that changed from the base of the PR and between e0a23e0 and 0209aaa.

📒 Files selected for processing (1)
  • packages/stream_chat_flutter/CHANGELOG.md
✅ Files skipped from review due to trivial changes (1)
  • packages/stream_chat_flutter/CHANGELOG.md

📝 Walkthrough

Walkthrough

stream_channel_list_item.dart now preserves the cached last message unless the channel state is up to date, and a new widget test file covers reload, rebind, and truncation subtitle behavior. A changelog entry records the fix.

Changes

Last-message preview flicker fix

Layer / File(s) Summary
isUpToDate-gated last-message cache
packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart, packages/stream_chat_flutter/CHANGELOG.md
_ChannelLastMessageWithStatusState and _ChannelLastMessageTextState now update _currentLastMessage only when channelState.isUpToDate is true; otherwise they derive latestLastMessage from the cached value and the current candidate. A changelog bullet is added.
Widget tests for subtitle behavior
packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart
New mock-driven widget tests cover preserving the last-known message during reload, updating correctly when reused for another channel, and showing empty-state text when truncation leaves the channel up to date.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested reviewers

  • renefloor

Poem

🐇 I hop where the previews used to flicker and sway,
Now the last message stays put through the gray.
Up to date? Then refresh; if not, let it rest—
Three tests and a note make the bunny feel blessed.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the primary UI fix in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bug/FLU-549_preserve_last_message_preview_during_channel_state_reloads

Warning

Tools execution failed with the following error:

Failed to run tools: Ping-pong health check failed


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

// (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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this after we calculate latestLastMessage? and set the _currentLastMessage to latestLastMessage?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an interesting edge-case which kinda prevents us doing this: While the _currentLastMessage will be properly populated in both cases, there is a subtle difference in the calculation of latestLastMessage, visible in the case where a channel is actually truncated (all messages removed).
If we move the statement after calculating the new latestLastMessage, the latestLastMessage will take into consideration the old _currentLastMessage. And for the truncation case, it means that it will wrongfully be calculated as final latestLastMessage = [message (null), _currentLastMessage (oldCachedMessage)].latest; - which would be wrong.
By keeping the if (channelState.isUpToDate) _currentLastMessage = message; before calculating latestLastMessage, it means that the new latest message will also be taken into consideration for the calculation: final latestLastMessage = [message (null), _currentLastMessage (null)].latest;

The concrete example where this logic fails:

  • If we assume a real channel truncation (channel.truncated event) - (messages = [], isUpToDate=true, cache=msg1 from the previous build):

    Order A — assign first, then snapshot:

    if (channelState.isUpToDate) {                                                                                                                                                   
      _currentLastMessage = message;   // cache = null                                                                                                                                
    }                                                  
    final latestLastMessage = [message, _currentLastMessage].latest;                                                                                                                   
    // = [null, null].latest = null  → empty text shown ✓         

    Order B — snapshot first, then assign:

    final latestLastMessage = [message, _currentLastMessage].latest;                                                                                                                   
    // = [null, msg1].latest = msg1  ← captured BEFORE the assignment         
    if (channelState.isUpToDate) {                                                                                                                                                     
      _currentLastMessage = message;   // cache = null, but latestLastMessage is already msg1                                                                                         
    }                                                                                                                                                                                
    // display msg1 → stale "hello" still shown ❌          

    But this whole discussion made me realise that we can make this whole thing a bit more readable:

    final Message? latestLastMessage;
    if (channelState.isUpToDate) {
        latestLastMessage = message;
        _currentLastMessage = latestLastMessage;
    } else {
        latestLastMessage = [message, _currentLastMessage].latest;
    }

    I will make this update!

…e_preview_during_channel_state_reloads' into bug/FLU-549_preserve_last_message_preview_during_channel_state_reloads
@VelikovPetar VelikovPetar marked this pull request as ready for review June 19, 2026 18:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart`:
- Line 11: The import statement for mocks.dart at the top of
stream_channel_list_item_test.dart uses a relative import path
(../../mocks.dart) which violates the repo's linting rule requiring package
imports. Replace the relative import with a package import by converting it to
use the package:stream_chat_flutter syntax, pointing to the mocks.dart file
location relative to the package root rather than using relative path traversal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0451c4f0-180b-4c98-b7cd-a7068bf7dcf2

📥 Commits

Reviewing files that changed from the base of the PR and between ceee490 and d9c1b9c.

📒 Files selected for processing (3)
  • packages/stream_chat_flutter/CHANGELOG.md
  • packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart
  • packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart

import 'package:mocktail/mocktail.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

import '../../mocks.dart';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a package import instead of a relative import.

import '../../mocks.dart'; violates the Dart import rule configured for this repo; switch it to a package: import so linting stays consistent across the package.

As per coding guidelines, **/*.dart: “Always use package imports instead of relative imports (always_use_package_imports)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart`
at line 11, The import statement for mocks.dart at the top of
stream_channel_list_item_test.dart uses a relative import path
(../../mocks.dart) which violates the repo's linting rule requiring package
imports. Replace the relative import with a package import by converting it to
use the package:stream_chat_flutter syntax, pointing to the mocks.dart file
location relative to the package root rather than using relative path traversal.

Source: Coding guidelines

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.77%. Comparing base (666e837) to head (0209aaa).

Files with missing lines Patch % Lines
.../channel_scroll_view/stream_channel_list_item.dart 50.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2774      +/-   ##
==========================================
+ Coverage   69.59%   69.77%   +0.17%     
==========================================
  Files         426      426              
  Lines       25676    25680       +4     
==========================================
+ Hits        17869    17917      +48     
+ Misses       7807     7763      -44     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…e_preview_during_channel_state_reloads' into bug/FLU-549_preserve_last_message_preview_during_channel_state_reloads
@VelikovPetar VelikovPetar merged commit 36d8a1c into master Jun 30, 2026
25 of 26 checks passed
@VelikovPetar VelikovPetar deleted the bug/FLU-549_preserve_last_message_preview_during_channel_state_reloads branch June 30, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants