Skip to content

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

Merged
VelikovPetar merged 8 commits into
masterfrom
bug/FLU-560_fix_message_updated_event_inserting_messages
Jul 14, 2026
Merged

fix(llc): Don't upsert out-of-window messages on message.updated/message.deleted events#2794
VelikovPetar merged 8 commits 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

    • Prevented message.updated and soft message.deleted from creating/insertions when the affected message is outside the currently loaded window, including correct handling for thread replies, pinned messages, and live-location expiry.
    • Ensured hard deletes remain a no-op for out-of-window messages.
  • New Features

    • Added an upsert control to message reconciliation so unknown out-of-window messages can be ignored (upsert defaults to true).
  • Tests

    • Expanded automated coverage for loaded-window behavior across update/delete scenarios.

…ted events

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

coderabbitai Bot commented Jul 1, 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: a5915c30-ead9-4135-8142-12ba5ea9f2d1

📥 Commits

Reviewing files that changed from the base of the PR and between 36f12dd and 57fde44.

📒 Files selected for processing (1)
  • packages/stream_chat/CHANGELOG.md

📝 Walkthrough

Walkthrough

Adds an upsert flag to ChannelClientState message reconciliation so websocket updates and soft deletes do not insert messages outside the loaded window. Includes merge-path changes, tests, and changelog entries.

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 use the upsert-disabled path, while hard deletes retain hard-delete behavior.
Propagate upsert through merge pipeline
packages/stream_chat/lib/src/client/channel.dart
Update and merge methods propagate upsert; disabled upserts skip unknown message IDs in channel and thread collections while retaining updates to loaded messages.
Validate loaded-window behavior
packages/stream_chat/test/src/client/channel_test.dart, packages/stream_chat/CHANGELOG.md
Tests cover channel, thread, pinned-message, and live-location behavior for out-of-window updates and deletes; changelog entries document the related changes and fixes.

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

Suggested reviewers: xsahil03x

Sequence Diagram(s)

sequenceDiagram
    participant WS as Websocket event
    participant Channel as ChannelClientState
    participant Merge as _mergeMessagesIntoExisting
    participant State as Channel state

    WS->>Channel: message.updated or soft message.deleted
    Channel->>Merge: reconcile with upsert: false
    alt message exists in loaded window
        Merge->>State: update existing message
    else message is outside loaded window
        Merge->>State: retain existing messages
    end
    Channel->>State: update pinned and live-location state when applicable
Loading
🚥 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 summarizes the main change: preventing out-of-window message upserts on update and 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

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.28%. Comparing base (0f7f1b6) to head (57fde44).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2794   +/-   ##
=======================================
  Coverage   71.27%   71.28%           
=======================================
  Files         430      430           
  Lines       26925    26930    +5     
=======================================
+ Hits        19191    19196    +5     
  Misses       7734     7734           

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

if (message == null) return;

return updateMessage(message);
return _updateMessages([message], upsert: false);

@xsahil03x xsahil03x Jul 6, 2026

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 add the upsert flag to updateMessage too and use it?

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.

Addressed here: 5bff89d

return deleteMessage(message, hardDelete: hardDelete);
if (hardDelete) return deleteMessage(message, hardDelete: true);
// Soft delete, update the message only if loaded (upsert: false)
return _updateMessages([message], upsert: false);

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.

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.

Addressed here: 5bff89d

Comment on lines +3212 to +3214
if (hardDelete) return deleteMessage(message, hardDelete: true);
// Soft delete, update the message only if loaded (upsert: false)
return updateMessage(message, upsert: false);

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 do the check here or instead modify the _deleteMessages and pass upsert: false always?

  void _deleteMessages(
    Iterable<Message> messages, {
    bool hardDelete = false,
  }) {
    if (messages.isEmpty) return;

    if (hardDelete) return _removeMessages(messages);
    return _updateMessages(messages, upsert: false);
  }

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.

I think this looks cleaner, I will update it like this! Thanks for the suggestion!

Comment on lines 3946 to 3947
_updatePinnedMessages(messages, update: update);
_updateActiveLiveLocations(messages);

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.

Lets also add the flag to these two

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.

I believe that the _updatePinnedMessages method is fine if it always does an upsertion. For example, if a message gets pinned, we would get a message.updated event for that message with the pinned indicator flipped. In such case, we should anyway add the message to the pinned messages state. Otherwise, we need to explicitly check if the message.updated event was received for the purpose of pinning a message.

Similarly for the _updateActiveLiveLocation -> I think that we are also fine if we always upsert them? In this case it would mean that a message outside of the loaded window was updated with a live location, but that is (I believe) already a valid scenario? We should get all live locations when loading the channel, unrelated to which page we load?

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.

Makes sense 👍🏼

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.

Done: 36f12dd

Comment on lines +3979 to +3985
// Don't create a phantom entry for a thread that wasn't loaded: with
// `upsert: false` an out-of-window reply is dropped, leaving the merged
// list empty. Writing it back would make `threads.containsKey(parentId)`
// report a thread that was never paged in.
if (existingThreadMessages == null && updatedThreadMessages.isEmpty) {
continue;
}

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.

We should probably early return if we are not upserting and there's no thread to update.

for (final MapEntry(key: thread, :value) in messagesByThread.entries) {
  final threadMessages = updatedThreads[thread] ?? <Message>[];

  // If the thread is not loaded and upsert is false, skip updating it.
  if (threadMessages.isEmpty && !upsert) continue;

  final updatedThreadMessages = _mergeMessagesIntoExisting(
    existing: threadMessages,
    toMerge: value,
    update: update,
    upsert: upsert,
  );

  // Update the thread with the modified message list.
  updatedThreads[thread] = updatedThreadMessages.toList();
}

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.

Done: 36f12dd

@VelikovPetar VelikovPetar merged commit 5c4c5a7 into master Jul 14, 2026
24 of 26 checks passed
@VelikovPetar VelikovPetar deleted the bug/FLU-560_fix_message_updated_event_inserting_messages branch July 14, 2026 13:04
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