Skip to content

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

Open
VelikovPetar wants to merge 5 commits into
masterfrom
bug/FLU-549_preserve_last_message_preview_during_channel_state_reloads
Open

fix(ui): preserve last-message preview during channel-state reloads#2774
VelikovPetar wants to merge 5 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
    • Fixed flickering of the last message preview during channel state reloads
    • Improved accuracy of message display when channel data is loading

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Two internal state classes (_ChannelLastMessageWithStatusState and _ChannelLastMessageTextState) in the channel list item widget now gate their cached _currentLastMessage update on channelState.isUpToDate, falling back to the cached value during non-current loads. A new test file covers three subtitle edge cases, and a changelog entry is added.

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
Both _ChannelLastMessageWithStatusState and _ChannelLastMessageTextState now update _currentLastMessage only when channelState.isUpToDate is true; when it is false, latestLastMessage is derived from the cached value and the current candidate instead. Changelog entry added.
Widget tests for ChannelListTileSubtitle
packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart
New test file with mock channel wiring helpers and three widget tests: last-known message is preserved when isUpToDate becomes false, subtitle reflects new channel state on rebind, and empty-state text appears on truncation while still up to date.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested reviewers

  • renefloor
  • Brazol

Poem

🐇 Hop hop, no more flicker in the list,
The last message holds firm through the mist.
isUpToDate? Cache it! Otherwise, wait—
The cached reply will patiently gate.
Three tests to prove the bunny was right,
The channel preview shines steady and bright! ✨

🚥 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 'fix(ui): preserve last-message preview during channel-state reloads' accurately and concisely summarizes the main change: fixing a flicker issue in the last-message preview by preserving it during channel-state reloads.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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

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 and usage tips.

// (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.66%. Comparing base (ceee490) to head (d9c1b9c).

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.48%   69.66%   +0.17%     
==========================================
  Files         426      426              
  Lines       25665    25669       +4     
==========================================
+ Hits        17834    17882      +48     
+ Misses       7831     7787      -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.

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