Skip to content

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

Open
VelikovPetar wants to merge 3 commits 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 3 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
    • Prevent message updates and soft deletes from inserting/creating records when the target message is outside the currently loaded window (including thread replies, pinned state, and live-location expiry effects).
    • Ensure hard deletes remain no-ops for out-of-window messages.
  • New Features
    • Added an option to control whether message updates may upsert unknown messages during state reconciliation.
  • Chores
    • Sanitized system environment details and improved network error handling to avoid parsing failures on non-JSON responses.
    • Expanded automated tests covering loaded-window guards.

…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: 8fcb9f4b-10c5-4144-b47d-d41e58959717

📥 Commits

Reviewing files that changed from the base of the PR and between 9f39525 and 18e1ce7.

📒 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
✅ Files skipped from review due to trivial changes (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

Sequence Diagram(s)

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

    WS->>Channel: message.updated or soft message.deleted
    Channel->>Update: updateMessage(message, upsert: false)
    Update->>Merge: merge with upsert disabled
    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

Suggested reviewers: renefloor, 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 describes the main fix: preventing out-of-window messages from being upserted on message 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 78.57143% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.08%. Comparing base (aab5029) to head (18e1ce7).

Files with missing lines Patch % Lines
packages/stream_chat/lib/src/client/channel.dart 78.57% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2794      +/-   ##
==========================================
- Coverage   71.08%   71.08%   -0.01%     
==========================================
  Files         429      429              
  Lines       26891    26898       +7     
==========================================
+ Hits        19116    19120       +4     
- Misses       7775     7778       +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.

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

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