Skip to content

fix(llc): Don't upsert out-of-window messages on message.updated/message.deleted events#2794

Open
VelikovPetar wants to merge 1 commit into
masterfrom
bug/FLU-560_fix_message_updated_event_inserting_messages
Open

fix(llc): Don't upsert out-of-window messages on message.updated/message.deleted events#2794
VelikovPetar wants to merge 1 commit into
masterfrom
bug/FLU-560_fix_message_updated_event_inserting_messages

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Submit a pull request

Linear: FLU-560

Github Issue: #

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

message.updated and soft message.deleted events were being unconditionally upserted into ChannelState.messages (and thread reply lists) via updateMessage / deleteMessage. When the target message wasn't in the currently loaded window — e.g. it lived on an older page the client hadn't paged in yet — this created a phantom entry in the sorted list, leaving a visible gap between the loaded slice and history.

Fix

  • _updateMessages (and its _updateThreadMessages / _updateChannelMessages / _mergeMessages helpers) now take an upsert flag.
  • message.updated event handler passes upsert: false.
  • message.deleted event handler passes upsert: false for soft deletes (hard deletes still route through deleteMessage unchanged).
  • When upsert: false and an id isn't already in the loaded list, the message is skipped for the channel/thread lists. Side effects such as pinnedMessages maintenance and activeLiveLocations expiration still fire (they don't depend on the message being in the window).

The guard is intentionally id-based and independent of isUpToDate — even at the latest page we may have paginated past older history and receive an event for a message no longer in memory.

Test plan

  • New channel_test.dart groups under messageUpdated and messageDeleted:
    • should NOT insert unknown message into messages list (out-of-window edit)
    • should update message in place when it IS in the loaded window
    • should still add to pinnedMessages when pinned:true even if not in loaded window
    • should NOT insert unknown reply into threads[parentId]
    • should still expire activeLiveLocations for out-of-window message
    • Soft delete equivalents: phantom-record guard, in-window mark-as-deleted, unpin-via-_pinIsValid, live-location clear, hard-delete no-op.

Screenshots / Videos

No UI changes.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed message updates and soft deletes so they no longer create unexpected messages when the affected message is outside the currently loaded chat window.
    • Improved handling of pinned messages, thread replies, and live location updates to stay accurate even when the target message isn’t loaded.
    • Hard deletes now remain no-ops for messages outside the loaded window, avoiding unwanted list changes.
  • Chores
    • Updated the changelog with the latest bug fix note.

…ted events

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an upsert flag to ChannelClientState's message reconciliation path so that message.updated and soft message.deleted websocket events no longer insert messages not already present in the loaded window. Includes new tests and a changelog entry.

Changes

Upsert guard for message reconciliation

Layer / File(s) Summary
Event handlers use upsert:false
packages/stream_chat/lib/src/client/channel.dart
messageUpdated and soft messageDeleted handlers now call _updateMessages([message], upsert: false) instead of directly upserting; hard deletes remain unchanged.
Propagate upsert through merge pipeline
packages/stream_chat/lib/src/client/channel.dart
_updateMessages, _updateThreadMessages, _updateChannelMessages, and _mergeMessagesIntoExisting add an upsert parameter; when false, both single-message and batch merge paths skip inserting ids not already present in the existing message list.
Tests and changelog
packages/stream_chat/test/src/client/channel_test.dart, packages/stream_chat/CHANGELOG.md
New test groups verify out-of-window message.updated/message.deleted events don't create phantom entries while still updating pinned/live-location state correctly; changelog documents the bugfix.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant WS as Websocket Event
    participant Channel as ChannelClientState
    participant Update as _updateMessages
    participant Merge as _mergeMessagesIntoExisting
    participant State as messages/threads/pinnedMessages

    WS->>Channel: messageUpdated / soft messageDeleted
    Channel->>Update: _updateMessages([message], upsert: false)
    Update->>Merge: _mergeMessagesIntoExisting(upsert: false)
    Merge->>Merge: check if message id exists in existing list
    alt id exists in loaded window
        Merge->>State: update message in place
    else id not in loaded window
        Merge->>State: skip insertion, return existing list
    end
    Channel->>State: still update pinnedMessages/activeLiveLocations if applicable
Loading

Suggested reviewers: xsahil03x

🚥 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 main fix for out-of-window message upserts on update/delete events.
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-560_fix_message_updated_event_inserting_messages

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.

@VelikovPetar VelikovPetar marked this pull request as ready for review July 1, 2026 17:35
@VelikovPetar VelikovPetar changed the title fix(llc): Don't upsert out-of-window messages on message.updated/deleted events fix(llc): Don't upsert out-of-window messages on message.updated/message.deleted events Jul 1, 2026

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/stream_chat/lib/src/client/channel.dart (1)

3963-3975: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Skip creating empty thread entries when upsert: false drops an unloaded reply.

At Lines 3973-3974, if updatedThreads[thread] is absent and _mergeMessagesIntoExisting(..., upsert: false) returns an empty list, this still writes thread: [] into _threads. That mutates/persists a thread that was never loaded and makes threads.containsKey(parentId) report a phantom thread.

Proposed fix
     final updatedThreads = {...threads};
     for (final MapEntry(key: thread, :value) in messagesByThread.entries) {
-      final threadMessages = updatedThreads[thread] ?? <Message>[];
+      final existingThreadMessages = updatedThreads[thread];
+      final threadMessages = existingThreadMessages ?? <Message>[];
       final updatedThreadMessages = _mergeMessagesIntoExisting(
         existing: threadMessages,
         toMerge: value,
         update: update,
         upsert: upsert,
       );
 
       // Update the thread with the modified message list.
+      if (existingThreadMessages == null && updatedThreadMessages.isEmpty) {
+        continue;
+      }
       updatedThreads[thread] = updatedThreadMessages.toList();
     }
🤖 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/lib/src/client/channel.dart` around lines 3963 - 3975,
Skip creating empty thread entries in the thread merge logic: in the channel
message-thread update path, if `updatedThreads[thread]` is missing and
`_mergeMessagesIntoExisting` returns no messages with `upsert: false`, do not
write that thread back into `_threads`. Update the loop in `channel.dart` so
`updatedThreads[thread] = ...` only happens when the merged list is non-empty or
the thread already exists, preserving the behavior of
`threads.containsKey(parentId)` and avoiding phantom unloaded threads.
🤖 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.

Outside diff comments:
In `@packages/stream_chat/lib/src/client/channel.dart`:
- Around line 3963-3975: Skip creating empty thread entries in the thread merge
logic: in the channel message-thread update path, if `updatedThreads[thread]` is
missing and `_mergeMessagesIntoExisting` returns no messages with `upsert:
false`, do not write that thread back into `_threads`. Update the loop in
`channel.dart` so `updatedThreads[thread] = ...` only happens when the merged
list is non-empty or the thread already exists, preserving the behavior of
`threads.containsKey(parentId)` and avoiding phantom unloaded threads.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dad998b6-e7d7-448d-840f-95abb02e919a

📥 Commits

Reviewing files that changed from the base of the PR and between 023687f and 9f39525.

📒 Files selected for processing (3)
  • packages/stream_chat/CHANGELOG.md
  • packages/stream_chat/lib/src/client/channel.dart
  • packages/stream_chat/test/src/client/channel_test.dart

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.03%. Comparing base (023687f) to head (9f39525).

Files with missing lines Patch % Lines
packages/stream_chat/lib/src/client/channel.dart 70.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2794      +/-   ##
==========================================
- Coverage   70.04%   70.03%   -0.01%     
==========================================
  Files         426      426              
  Lines       25680    25685       +5     
==========================================
+ Hits        17987    17989       +2     
- Misses       7693     7696       +3     

☔ 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.

1 participant