Skip to content

Improve channel list and model conversion performance#4165

Merged
nuno-vieira merged 21 commits into
developfrom
perf/channel-list-performance-improvements
Jul 13, 2026
Merged

Improve channel list and model conversion performance#4165
nuno-vieira merged 21 commits into
developfrom
perf/channel-list-performance-improvements

Conversation

@nuno-vieira

@nuno-vieira nuno-vieira commented Jul 9, 2026

Copy link
Copy Markdown
Member

🔗 Issue Links

🎯 Goal

Reduce the CPU cost of loading and updating the channel list, especially for channels with many members and long message histories. The work targets the database-to-model conversion path and repeated main-thread work observed while profiling the channel list.

📝 Summary

  • Convert ChatChannel and ChatMessage to final, immutable reference types to avoid repeated value copies during conversions.
  • Prewarm a cache for channel read lookups to avoid repeated scans.
  • Skip building and discarding latest messages beyond the truncation window.
  • Avoid rebuilding entity models twice per background database change.
  • Skip decoding extra data when it is an empty JSON object.
  • Avoid a redundant queue hop when reading chatClientConfig.

🛠 Implementation

The main change is making ChatChannel/ChatMessage final classes, which removes a class of redundant copying in the DB observers and conversion helpers. The remaining commits each remove specific redundant work on the conversion / background-observer path (double model rebuilds, unnecessary message materialization past the truncation window, empty extra-data decoding, and a queue hop).

🎨 Showcase

N/A — no user-facing UI changes.

🧪 Manual Testing Notes

Load a channel list where channels have many members and long histories, and confirm channel list synchronize/scroll no longer hangs the main thread. Behavior should be unchanged.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

Summary by CodeRabbit

Summary

  • New Features
    • Added persisted, stable identifiers for channel reads with pre-warming and automatic backfilling to prevent duplicate entries.
  • Bug Fixes
    • Improved membership state updates for channel membership events.
    • Fixed latest-message selection so truncation and oldest limits apply consistently.
  • Performance
    • Expanded cached-item reuse across channel, user, message, poll, member, and related observers for faster updates.
    • Faster decoding for empty custom JSON payloads.
  • Breaking/Behavioral Changes
    • ChatChannel and ChatMessage are now reference types; changing(...) behavior was updated.
  • Tests
    • Added/expanded coverage for read identifiers, cache reuse, observer behavior, and decoder edge cases.

Add a composite id (cid/userId) to ChannelReadDTO so reads can be
resolved through the prewarmed cache and a single id-based fetch
instead of a compound predicate query per read. Falls back to the
legacy lookup and backfills the id for existing entries, enabling a
lightweight Core Data migration.
Eliminates per-copy refcount overhead (30-47 bumps per ChatChannel copy,
similar for ChatMessage) by making both types reference types. Since they
were already immutable snapshots with value-based Equatable/Hashable, the
semantic change is minimal while the performance gain is significant for
hot paths like channel list diffing and background DTO-to-model mapping.

Key changes:
- ChatChannel: struct → final class, membership becomes let, changing()
  membership parameter uses double-optional to distinguish "keep existing"
  from "explicitly set to nil"
- ChatMessage: struct → final class, init(fromDTO:) converted to static
  factory method, DraftMessage convenience init delegates to designated init
- LivestreamChatHandler: membership mutations folded into changing() calls
…indow

channelMessageDTOs is sorted descending by createdAt, so once a message
falls outside the oldestMessageAt/truncatedAt window, every older message
after it does too. Filtering the DTOs with prefix(while:) before building
models, instead of filtering already-built ChatMessage models twice
afterward, avoids constructing messages that get discarded and removes
two redundant array copies.
BackgroundEntityDatabaseObserver never passed itemReuseKeyPaths to the
underlying converter, so every content-change notification decoded the
item once via the change aggregator and then again from scratch when
refreshing the cached snapshot. This doubled the CoreData decode cost
for CurrentChatUser (including its muted channels), single-channel,
member, message, poll, and user group observers on every update.
decodeRawJSON ran a full Decodable pass through JSONDecoder for every
entity's extra data, even though most users, members, messages, and
channels have none and are persisted with the literal {} bytes. Short
circuit that common case to avoid the decode overhead on every model
conversion.
chatClientConfig is written once when a context is created and never
mutated again, yet every read went through performAndWait even though
callers (DTO -> model conversion) are already running on the context's
queue. This getter is invoked once per message/member/channel created,
so the extra confinement check added up to measurable overhead when
decoding channels with many nested models.
@nuno-vieira
nuno-vieira requested a review from a team as a code owner July 9, 2026 19:01
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

This PR adds observer item reuse key paths, composed channel-read identifiers, immutable reference-type channel and message models, message snapshot factory construction, and database and JSON decoding updates with tests.

Changes

Observer Reuse and Controller Wiring

Layer / File(s) Summary
BackgroundEntityDatabaseObserver item reuse
Sources/StreamChat/Controllers/DatabaseObserver/BackgroundEntityDatabaseObserver.swift, Sources/StreamChat/Controllers/*Controller/*.swift, Tests/StreamChatTests/BackgroundEntityDatabaseObserver_Tests.swift, StreamChat.xcodeproj/project.pbxproj
Adds observer reuse key paths, wires reuse mappings through controller builders, and tests reuse behavior.

ChannelReadDTO Composed Identifier

Layer / File(s) Summary
ChannelReadDTO composed identifier
Sources/StreamChat/APIClient/Endpoints/Payloads/Identifiable*.swift, Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift, Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/..., Tests/StreamChatTests/**/ChannelReadDTO*_Tests.swift
Adds composed read identifiers, id-based loading, legacy backfill, cache insertion, Core Data indexing, and persistence tests.

Channel and Message Models

Layer / File(s) Summary
Channel and message model updates
Sources/StreamChat/Models/Channel.swift, Sources/StreamChat/Controllers/ChannelController/LivestreamChatHandler.swift, Sources/StreamChat/Models/ChatMessage.swift, Sources/StreamChat/Models/DraftMessage.swift
Converts channel and message models to final classes, changes membership updates to double-optional handling, and updates draft conversion.

Message Snapshot Construction

Layer / File(s) Summary
MessageDTO ChatMessage factory
Sources/StreamChat/Database/DTOs/MessageDTO.swift
Uses ChatMessage.create(fromDTO:depth:) for snapshot and reply construction and simplifies transformer returns.

Database and Decoding Updates

Layer / File(s) Summary
DTO and decoding fast paths
Sources/StreamChat/Database/DTOs/ChannelDTO.swift, Sources/StreamChat/Database/DatabaseSession.swift, Sources/StreamChat/Utils/Codable+Extensions.swift, Tests/StreamChatTests/Utils/Codable+Extensions_Tests.swift, CHANGELOG.md
Changes latest-message filtering, reads chatClientConfig directly from context user info, adds an empty-object JSON decoding fast path with tests, and records the model and performance updates.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: 🟢 QAed

Suggested reviewers: laevandus, martinmitrevski

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main goal of improving channel list synchronization and model conversion performance.
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 perf/channel-list-performance-improvements

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.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
1 Warning
⚠️ Big PR

Generated by 🚫 Danger

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
Sources/StreamChat/Database/DTOs/MessageDTO.swift (1)

1813-1819: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Increment depth when materializing latestReplies
latestReplies calls ChatMessage.create(fromDTO:depth:) directly, so nested replies reuse the same depth while other relationship fields go through relationshipAsModel(depth:). That bypasses the recursion guard for reply chains; switch this to relationshipAsModel(depth:) or increment the depth here.

🤖 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 `@Sources/StreamChat/Database/DTOs/MessageDTO.swift` around lines 1813 - 1819,
The latestReplies materialization in ChatMessage.create(fromDTO:depth:) is
bypassing the usual depth increment, so nested replies keep reusing the same
recursion level. Update the latestReplies mapping to go through
relationshipAsModel(depth:) or otherwise pass depth + 1 when creating each
ChatMessage, matching the behavior used for other relationship fields and
ensuring the recursion guard works correctly.
🧹 Nitpick comments (3)
Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift (1)

68-96: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Double fetch on cache-miss for brand-new reads.

For a genuinely new read (not in cache, no row with matching id, no legacy row), this now does two sequential Core Data fetches (load(id:) at Line 79 then load(cid:userId:) at Line 85) before inserting, versus one fetch previously. This is exactly the "many members / long history" first-sync scenario the PR targets, so the extra round-trip per new read partially offsets the intended CPU win.

Consider merging the id-lookup and legacy-lookup into a single fetch with a combined predicate, backfilling id only when it's found nil:

♻️ Proposed refactor to merge the two fallback fetches
-        if let existing = load(id: readId, context: context) {
-            return existing
-        }
-
-        // Fallback for reads persisted before the `id` attribute existed: look them up by the
-        // compound predicate and backfill the identifier so subsequent lookups can use the id.
-        if let legacy = load(cid: cid, userId: userId, context: context) {
-            legacy.id = readId
-            return legacy
-        }
+        // Single fetch covering both the fast id-based path and the legacy compound-predicate
+        // fallback, instead of two separate round-trips.
+        let combinedRequest = NSFetchRequest<ChannelReadDTO>(entityName: ChannelReadDTO.entityName)
+        combinedRequest.predicate = NSPredicate(
+            format: "id == %@ OR (channel.cid == %@ && user.id == %@)",
+            readId, cid.rawValue, userId
+        )
+        if let existing = load(by: combinedRequest, context: context).first {
+            if existing.id == nil {
+                existing.id = readId
+            }
+            return existing
+        }
🤖 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 `@Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift` around lines 68 - 96,
The loadOrCreate flow in ChannelReadDTO performs two Core Data fetches on a
cache miss for a brand-new read, first via load(id:context:) and then via
load(cid:userId:context:), which adds avoidable overhead. Refactor the fallback
path to use a single lookup that can match either the new id or the legacy
cid/userId identity, then backfill legacy records by setting id only when
needed. Keep the existing cache check, existing load(id:) fast path, and the
insertNewObject branch intact while consolidating the legacy fallback logic
inside loadOrCreate.
Sources/StreamChat/Models/ChatMessage.swift (1)

12-12: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider documenting why @unchecked Sendable is safe here.

All stored properties are let, but _quotedMessage: BoxedAny? type-erases its wrapped value, so the compiler can't verify Sendability automatically. A short comment noting the invariant (fully immutable, and boxed values are only read via getValue()) would help future maintainers scope/justify this exception.

As per coding guidelines, "**/*.swift: ... Use Sendable conformances where needed for cross-isolation transfers and avoid data races."

🤖 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 `@Sources/StreamChat/Models/ChatMessage.swift` at line 12, Add a brief comment
near the ChatMessage declaration explaining why the `@unchecked` Sendable
conformance is safe: the type is fully immutable because all stored properties
are let, and the type-erased _quotedMessage: BoxedAny? is only ever read through
getValue(), so the compiler cannot prove Sendability but the runtime invariant
keeps cross-isolation use safe.

Source: Coding guidelines

Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents (1)

152-152: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider adding a uniqueness constraint on the new ChannelReadDTO.id.

The new composed id (cid + userId) is added with a fetch index but no uniquenessConstraints, unlike other entities that use an id-style identifier (e.g. MessageDTO, MemberDTO, UserDTO all constrain on id). Since loadOrCreate now relies on id-based lookup as the primary path, a DB-level constraint would provide defense-in-depth against duplicate rows for the same channel/user pair, complementing the existing cache/backfill logic in ChannelReadDTO.loadOrCreate.

♻️ Proposed addition
     <entity name="ChannelReadDTO" representedClassName="ChannelReadDTO" syncable="YES">
         <attribute name="id" optional="YES" attributeType="String"/>
         ...
         <fetchIndex name="id">
             <fetchIndexElement property="id" type="Binary" order="ascending"/>
         </fetchIndex>
+        <uniquenessConstraints>
+            <uniquenessConstraint>
+                <constraint value="id"/>
+            </uniquenessConstraint>
+        </uniquenessConstraints>
     </entity>

Also applies to: 161-163

🤖 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
`@Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents`
at line 152, Add a uniqueness constraint for ChannelReadDTO.id in the Core Data
model, since loadOrCreate now depends on the composed cid+userId identifier as
the primary lookup path. Update the StreamChatModel entity definition for
ChannelReadDTO so its id field is constrained like MessageDTO, MemberDTO, and
UserDTO, and ensure the same constraint is applied consistently where the model
is defined. Keep the existing fetch index, but make the id constraint the
DB-level guard against duplicate channel/user rows.
🤖 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 `@Sources/StreamChat/Database/DTOs/MessageDTO.swift`:
- Around line 1813-1819: The latestReplies materialization in
ChatMessage.create(fromDTO:depth:) is bypassing the usual depth increment, so
nested replies keep reusing the same recursion level. Update the latestReplies
mapping to go through relationshipAsModel(depth:) or otherwise pass depth + 1
when creating each ChatMessage, matching the behavior used for other
relationship fields and ensuring the recursion guard works correctly.

---

Nitpick comments:
In `@Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift`:
- Around line 68-96: The loadOrCreate flow in ChannelReadDTO performs two Core
Data fetches on a cache miss for a brand-new read, first via load(id:context:)
and then via load(cid:userId:context:), which adds avoidable overhead. Refactor
the fallback path to use a single lookup that can match either the new id or the
legacy cid/userId identity, then backfill legacy records by setting id only when
needed. Keep the existing cache check, existing load(id:) fast path, and the
insertNewObject branch intact while consolidating the legacy fallback logic
inside loadOrCreate.

In
`@Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents`:
- Line 152: Add a uniqueness constraint for ChannelReadDTO.id in the Core Data
model, since loadOrCreate now depends on the composed cid+userId identifier as
the primary lookup path. Update the StreamChatModel entity definition for
ChannelReadDTO so its id field is constrained like MessageDTO, MemberDTO, and
UserDTO, and ensure the same constraint is applied consistently where the model
is defined. Keep the existing fetch index, but make the id constraint the
DB-level guard against duplicate channel/user rows.

In `@Sources/StreamChat/Models/ChatMessage.swift`:
- Line 12: Add a brief comment near the ChatMessage declaration explaining why
the `@unchecked` Sendable conformance is safe: the type is fully immutable because
all stored properties are let, and the type-erased _quotedMessage: BoxedAny? is
only ever read through getValue(), so the compiler cannot prove Sendability but
the runtime invariant keeps cross-isolation use safe.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56434f31-d2da-4479-a4e7-a99f493d38eb

📥 Commits

Reviewing files that changed from the base of the PR and between 712984a and 9f5bc0a.

📒 Files selected for processing (27)
  • Sources/StreamChat/APIClient/Endpoints/Payloads/IdentifiableModel.swift
  • Sources/StreamChat/APIClient/Endpoints/Payloads/IdentifiablePayload.swift
  • Sources/StreamChat/Controllers/ChannelController/ChannelController.swift
  • Sources/StreamChat/Controllers/ChannelController/LivestreamChatHandler.swift
  • Sources/StreamChat/Controllers/CurrentUserController/CurrentUserController.swift
  • Sources/StreamChat/Controllers/DatabaseObserver/BackgroundEntityDatabaseObserver.swift
  • Sources/StreamChat/Controllers/MemberController/MemberController.swift
  • Sources/StreamChat/Controllers/MessageController/MessageController.swift
  • Sources/StreamChat/Controllers/PollController/PollController.swift
  • Sources/StreamChat/Controllers/PollController/PollVoteListController.swift
  • Sources/StreamChat/Controllers/UserController/UserController.swift
  • Sources/StreamChat/Controllers/UserGroupController/UserGroupController.swift
  • Sources/StreamChat/Database/DTOs/ChannelDTO.swift
  • Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
  • Sources/StreamChat/Database/DTOs/MessageDTO.swift
  • Sources/StreamChat/Database/DatabaseSession.swift
  • Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
  • Sources/StreamChat/Models/Channel.swift
  • Sources/StreamChat/Models/ChatMessage.swift
  • Sources/StreamChat/Models/DraftMessage.swift
  • Sources/StreamChat/Utils/Codable+Extensions.swift
  • StreamChat.xcodeproj/project.pbxproj
  • Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiableModel_Tests.swift
  • Tests/StreamChatTests/APIClient/Endpoints/Payloads/IdentifiablePayload_Tests.swift
  • Tests/StreamChatTests/BackgroundEntityDatabaseObserver_Tests.swift
  • Tests/StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift
  • Tests/StreamChatTests/Utils/Codable+Extensions_Tests.swift

Hoist the coalesced values in ChatChannel.changing and
ChatMessage.changing into explicit locals so the type-checker does
not have to resolve all of them inside a single large initializer.
…om:GetStream/stream-chat-swift into perf/channel-list-performance-improvements
Use relationshipAsModel when materializing latestReplies so nested
replies increment the recursion depth like other relationship fields.
The channel list performance entry was missing the 1 MB SDK size
reduction achieved by converting ChatChannel/ChatMessage to classes.
Comment on lines +146 to +152
channelReads.fillIds(cache: &cache)
// A channel read's id is composed of the channel cid and the user id, so it can only be
// resolved here where the parent cid is known.
channelReads.forEach { read in
read.fillIds(cache: &cache)
let readId = ChannelReadDTO.createId(cid: channel.cid, userId: read.user.id)
cache[ChannelReadDTO.className, default: []].insert(readId)
}

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.

Would be nice to have the fillIds wrapper to keep the callsite similar to others.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done 👍

try $0.asModel() as ChatChannel
}
},
itemReuseKeyPaths: (\ChatChannel.cid.rawValue, \ChannelDTO.cid)

@laevandus laevandus Jul 10, 2026

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.

We need to do additional testing with channel controller because this relies on CoreData detecting changes correctly when submodels also change (eg willSave we add here and there).

Never tried but there is an option of changing model reuse implementation and not use list changes and compare final ChatChannels instead inside the observer. More CPU work, but might be more correct. But outside of scope of this PR.

Let's add these to state layer as well for consistency.

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.

good point about the state layer, especially if we want to use it under the hood here as well

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added already 👍

Comment on lines +370 to +372
let membership: ChatChannelMember?? = memberAddedEvent.member.id == currentUserId
? .some(memberAddedEvent.member)
: nil

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.

Double optional looks a bit odd, for readability it would be nice to avoid it. WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This one is actually needed because we need to distinguish between:

  • There is a value
  • There is no value
  • The value must be reset to nil

Found it weird as well initially, but it is actually a clean solution for this particular case

Comment on lines +589 to -598
// channelMessageDTOs is sorted descending by createdAt, and both bounds below are
// simple thresholds, so the first message that fails one of them guarantees every
// following (older) message fails too — prefix(while:) can stop there instead of
// scanning the rest, and we never build a model we'd discard.
let lowerBound = max(
dto.oldestMessageAt?.bridgeDate ?? .distantPast,
dto.truncatedAt?.bridgeDate ?? .distantPast
)
return channelMessageDTOs
.prefix(clientConfig.localCaching.chatChannel.latestMessagesLimit)
.prefix(while: { $0.createdAt.bridgeDate >= lowerBound })
.compactMap { try? $0.relationshipAsModel(depth: depth) }
if let oldest = dto.oldestMessageAt?.bridgeDate {
messages = messages.filter { $0.createdAt >= oldest }
}
if let truncated = dto.truncatedAt?.bridgeDate {
messages = messages.filter { $0.createdAt >= truncated }
}
return messages

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.

Makes sense, good change to reduce model conversions.

if let legacy = load(cid: cid, userId: userId, context: context) {
legacy.id = readId
return legacy
}

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.

Just wondering if this could be skipped if CoreData model has id as non-optional and then does the DatabaseContainer recreatePersistentStore get automatically invoked or not. It would flush away old data then.

@nuno-vieira nuno-vieira Jul 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah I guess this might not be needed, since we don't have migrations. I will review this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed 👍

Comment on lines +10 to +17
/// `chatClientConfig` is written once right after the context is created (see `setChatClientConfig`),
/// before the context is ever handed out, and is never mutated afterwards. Every read of it happens
/// from code that is already executing on this context's queue (e.g. DTO -> model conversion), so
/// wrapping the read in `performAndWait` only adds a redundant queue-confinement check. This getter
/// is called very frequently (once per model created while decoding a channel/message/member), so
/// avoiding that overhead matters.
var chatClientConfig: ChatClientConfig? {
nonisolated(unsafe) var config: ChatClientConfig?
performAndWait {
config = userInfo[Self.chatClientConfigKey] as? ChatClientConfig
}
return config
userInfo[Self.chatClientConfigKey] as? ChatClientConfig

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.

This was added in #3566. I wonder if things have changed since then and it is really safe now.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, will check

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reverted this since it was risky and the impact is not huge

Move composed read id registration into ChannelReadPayload so
ChannelPayload.fillIds no longer needs inline channel read handling.
We do not support Core Data migrations, so reads without a composed
id do not need a compound-predicate lookup path in loadOrCreate.
Extend StateLayerDatabaseObserver entity observers with
itemReuseKeyPaths and wire it up for channel, current user, and
message state to match controller-layer observer behavior.
Keep queue confinement when accessing chatClientConfig from DTO
conversion paths that may not already be on the context queue.
Replace @_disfavoredOverload with a distinct parameter label so entity
and list observer inits no longer collide when item reuse is enabled.

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

these changes look good to me - but let's test both UIKit and SwiftUI, cc @testableapple. Will review, test and comment now on the SwiftUI one.

try $0.asModel() as ChatChannel
}
},
itemReuseKeyPaths: (\ChatChannel.cid.rawValue, \ChannelDTO.cid)

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.

good point about the state layer, especially if we want to use it under the hood here as well

@testableapple testableapple added the 🟢 QAed A PR that was QAed label Jul 13, 2026
Reading a channel's draft message wasn't part of the prefetched
relationships, causing Core Data to fault on it individually per
channel when reading the channel list.
Saving a draft always deleted the existing draft message and created a
new one with a fresh id, even when resaving an otherwise-unchanged
draft. This caused unnecessary relationship churn and triggered channel
list observers on every save.
Needed to add test coverage for suppressing typing keystroke events
while a composer is being populated from a draft.
@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

SDK Size

title develop branch diff status
StreamChat 7.53 MB 7.13 MB -412 KB 🚀
StreamChatUI 4.27 MB 3.62 MB -661 KB 🚀
StreamChatCommonUI 0.94 MB 0.94 MB 0 KB 🟢

@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

StreamChat XCSize

Object Diff (bytes)
Thread.o -64596
MessageReminder.o -64520
MentionSuggestionsProvider.o -45588
Channel.o -40223
LivestreamChatHandler.o -35640
Show 96 more objects
Object Diff (bytes)
SendMessageInterceptor.o -22504
ChatMessage.o -21507
ChannelController.o -12492
MessageController.o -9008
MessageUpdater.o -8748
MessageDTO.o -7719
ChannelListLinker.o -7568
ChatState+Observer.o -5318
CurrentUserController.o +5210
MessageRepository.o -4482
ChannelUpdater.o -3904
EnhancedMentionSuggestionsProvider.o -3764
Codable+Extensions.o +3498
ChatRemoteNotificationHandler.o -3336
GroupedChannelListLinker.o -3060
MessageSender.o -2526
ReadStateHandler.o -2500
ChannelListUpdater.o -2496
Chat.o -2452
LivestreamChannelController.o -2372
ChatState.o -2270
ManualEventHandler.o -2256
MessageEvents.o -1920
UserGroupController.o +1893
Sequence+CompactMapLoggingError.o -1880
MessageEditor.o -1620
MessageState+Observer.o -1512
ChannelEvents.o -1432
NotificationEvents.o -1344
PollController.o +1124
LivestreamChatState.o -1080
DraftMessagesRepository.o -944
MessagePayload+asModel.o -864
UnreadMessageLookup.o -844
ChannelListPayload.o +728
DataStore.o -700
ChannelPayload+asModel.o -596
ReactionEvents.o -528
ListChange.o -496
IdentifiablePayload.o +484
AppSettings+Extensions.o -484
MessageState.o -480
LivestreamChat.o -456
LivestreamChannelController+Combine.o -436
SyncOperations.o -436
MemberListController.o -432
ChannelRepository.o -416
ActiveLiveLocationsEndTimeTracker.o -408
ChannelReadDTO.o +377
ChatClient+ChannelController.o -356
ConnectedUserState+Observer.o +356
ThreadEvents.o -340
MulticastDelegate.o +328
PollVoteListController.o +328
SharedLocationDTO.o +320
DefaultMentionSuggestionsProvider.o +300
MemberModelDTO.o -284
AnyAttachmentPayload.o -278
ChatClient.o +252
StreamCore.o -232
CurrentUserDTO.o -224
AttachmentDownloader.o -212
ChannelDTO.o -190
ChatMessageAudioAttachment.o -184
MessageSearchController.o +184
MessageDeliveryCriteriaValidator.o -180
User.o -175
UploadConfig.o +172
MessageSearchQuery.o -152
UserController.o +144
UserListState+Observer.o -144
ChannelDeliveredMiddleware.o -140
IdentifiableModel.o +136
StreamModelsTransformer.o -132
ConnectedUserState.o -124
MessageTranslationsPayload.o -124
DefaultEndpoints.o -108
MarkdownParser.o -104
PushPreferenceDTO.o -100
DraftEvents.o -100
ThreadParticipant.o +84
ThreadDTO.o -80
ChatChannelWatcherListController.o -76
ChannelList.o -76
MentionSuggestion.o -76
DraftMessage.o +72
UserPayload+asModel.o +72
UserEvents.o -64
MessageReminderDTO.o -60
AudioAnalysisEngine.o +56
ConnectedUser.o +52
PollDTO.o -50
MemberController+Combine.o -48
UserGroupDTO.o +48
ChannelListSortingKey.o +44
ReactionListUpdater.o -44

@Stream-SDK-Bot

Copy link
Copy Markdown
Collaborator

StreamChatUI XCSize

Object Diff (bytes)
ChatChannelListItemView.o -121408
ComposerVC.o -97996
InputChatMessageView.o -91726
QuotedChatMessageView.o -81166
ChatMessageHeaderDecoratorView.o -69482
Show 53 more objects
Object Diff (bytes)
ChatMessageDeliveryStatusView.o -68718
ChatThreadListItemView.o -68156
GalleryVC.o -25508
ChatMessageListVC.o -17926
ChatMessageContentView.o -6352
ChatChannelVC.o -6296
ChatMessageActionsVC.o -2800
ChatChannelAvatarView.o -1868
ChatThreadVC.o -1664
ChatMessagePopupVC.o -1112
ChatChannelListVC.o +960
PollAttachmentViewInjector.o -832
ChatMessageListRouter.o -824
ChatThreadRepliesCountDecorationView.o -730
ChatMessageReactionsPickerVC.o -702
ChatUnreadMessagesCountDecorationView.o -686
MixedAttachmentViewInjector.o -676
Algorithm.o +672
ChatChannelHeaderView.o -658
ChatMessageLayoutOptionsResolver.o -596
ChatMessageSearchVC.o -580
FileAttachmentViewInjector.o -580
MessageActionsTransitionController.o -488
GalleryAttachmentViewInjector.o -444
ChatThreadHeaderView.o -404
PollCreationFeatureCell.o +352
ChatChannelSearchVC.o -352
AudioQueuePlayerNextItemProvider.o -328
VoiceRecordingAttachmentViewInjector.o -260
GiphyAttachmentViewInjector.o -212
LinkAttachmentViewInjector.o -208
SwipeToReplyGestureHandler.o -192
AttachmentViewInjector.o -164
ImagePrefetcher.o +160
UnsupportedAttachmentViewInjector.o -160
ChatMessageListView.o -96
Components.o -96
ChatMessageListView+DiffKit.o +84
TaskFetchOriginalData.o -80
ChatMessageReactions+Types.o -80
ChatMessage+Extensions.o -80
CommandLabelView.o -80
StatefulScrollViewPaginationHandler.o +76
ChatMessageReactionsView.o -68
ContainerStackView.o -68
MediaLoader+UIKit.o -68
ChatMessageReactionAuthorsVC.o -60
ChatMessageVoiceRecordingAttachmentListView+ItemView.o -60
StreamChat.tbd +56
PollAllOptionsListItemCell.o +48
ChatThreadListVC.o +48
PollFeature.o +44
ImagePipeline.o -44

@github-actions

Copy link
Copy Markdown

Public Interface

+ public final class ChatChannel: @unchecked Sendable  
+ 
+   public let cid: ChannelId
+   public let name: String?
+   public let imageURL: URL?
+   public let lastMessageAt: Date?
+   public let createdAt: Date
+   public let updatedAt: Date
+   public let deletedAt: Date?
+   public let truncatedAt: Date?
+   public let isHidden: Bool
+   public let createdBy: ChatUser?
+   public let config: ChannelConfig
+   public let filterTags: Set<String>
+   public let ownCapabilities: Set<ChannelCapability>
+   public let isFrozen: Bool
+   public let isDisabled: Bool
+   public let isBlocked: Bool
+   public let memberCount: Int
+   public let messageCount: Int?
+   public let lastActiveMembers: [ChatChannelMember]
+   public let currentlyTypingUsers: Set<ChatUser>
+   public let membership: ChatChannelMember?
+   public var isArchived: Bool
+   public var isPinned: Bool
+   public let lastActiveWatchers: [ChatUser]
+   public let watcherCount: Int
+   public let team: TeamId?
+   public let unreadCount: ChannelUnreadCount
+   public let latestMessages: [ChatMessage]
+   public let lastMessageFromCurrentUser: ChatMessage?
+   public let pinnedMessages: [ChatMessage]
+   public let pendingMessages: [ChatMessage]
+   public let reads: [ChatChannelRead]
+   public let muteDetails: MuteDetails?
+   public var isMuted: Bool
+   public let cooldownDuration: Int
+   public let extraData: [String: RawJSON]
+   public let draftMessage: DraftMessage?
+   public let activeLiveLocations: [SharedLocation]
+   public let pushPreference: PushPreference?
+   
+ 
+   public func replacing(name: String?,imageURL: URL?,extraData: [String: RawJSON]?)-> ChatChannel
+   public func changing(name: String? = nil,imageURL: URL? = nil,lastMessageAt: Date? = nil,createdAt: Date? = nil,deletedAt: Date? = nil,updatedAt: Date? = nil,truncatedAt: Date? = nil,isHidden: Bool? = nil,createdBy: ChatUser? = nil,config: ChannelConfig? = nil,filterTags: Set<String>? = nil,ownCapabilities: Set<ChannelCapability>? = nil,isFrozen: Bool? = nil,isDisabled: Bool? = nil,isBlocked: Bool? = nil,reads: [ChatChannelRead]? = nil,members: [ChatChannelMember]? = nil,membership: ChatChannelMember?? = nil,memberCount: Int? = nil,watchers: [ChatUser]? = nil,watcherCount: Int? = nil,team: TeamId? = nil,cooldownDuration: Int? = nil,pinnedMessages: [ChatMessage]? = nil,pushPreference: PushPreference? = nil,currentlyTypingUsers: Set<ChatUser>? = nil,extraData: [String: RawJSON]? = nil)-> ChatChannel

+ public final class ChatMessage: Identifiable, @unchecked Sendable  
+ 
+   public let id: MessageId
+   public let cid: ChannelId?
+   public let text: String
+   public let type: MessageType
+   public let command: String?
+   public let createdAt: Date
+   public let locallyCreatedAt: Date?
+   public let updatedAt: Date
+   public let deletedAt: Date?
+   public let textUpdatedAt: Date?
+   public let arguments: String?
+   public let parentMessageId: MessageId?
+   public let showReplyInChannel: Bool
+   public let replyCount: Int
+   public let extraData: [String: RawJSON]
+   public var quotedMessage: ChatMessage?
+   public let draftReply: DraftMessage?
+   public let reminder: MessageReminderInfo?
+   public let isBounced: Bool
+   public let isSilent: Bool
+   public let isShadowed: Bool
+   public let deletedForMe: Bool
+   public let reactionScores: [MessageReactionType: Int]
+   public let reactionCounts: [MessageReactionType: Int]
+   public let reactionGroups: [MessageReactionType: ChatMessageReactionGroup]
+   public let author: ChatUser
+   public let mentionedUsers: Set<ChatUser>
+   public let mentionedHere: Bool
+   public let mentionedChannel: Bool
+   public let mentionedGroups: Set<UserGroupMention>
+   public let mentionedRoles: [String]
+   public let threadParticipants: [ChatUser]
+   public var threadParticipantsCount: Int
+   public var attachmentCounts: [AttachmentType: Int]
+   public let latestReplies: [ChatMessage]
+   public let localState: LocalMessageState?
+   public let isFlaggedByCurrentUser: Bool
+   public let latestReactions: Set<ChatMessageReaction>
+   public let currentUserReactions: Set<ChatMessageReaction>
+   public var currentUserReactionsCount: Int
+   public let isSentByCurrentUser: Bool
+   public let pinDetails: MessagePinDetails?
+   public let translations: [TranslationLanguage: String]?
+   public let originalLanguage: TranslationLanguage?
+   public let moderationDetails: MessageModerationDetails?
+   public let readBy: Set<ChatUser>
+   public var readByCount: Int
+   public let poll: Poll?
+   public let sharedLocation: SharedLocation?
+   public let channelRole: MemberRole?
+   
+ 
+   public func translatedText(for language: TranslationLanguage)-> String?
+   public func changing(text: String? = nil,type: MessageType? = nil,state: LocalMessageState? = nil,command: String? = nil,arguments: String? = nil,attachments: [AnyChatMessageAttachment]? = nil,translations: [TranslationLanguage: String]? = nil,originalLanguage: TranslationLanguage? = nil,moderationDetails: MessageModerationDetails? = nil,readBy: Set<ChatUser>? = nil,deletedAt: Date? = nil,extraData: [String: RawJSON]? = nil)-> ChatMessage
+   public func replacing(text: String?,extraData: [String: RawJSON]?,attachments: [AnyChatMessageAttachment]?)-> ChatMessage
+   public func replacing(text: String?,type: MessageType,state: LocalMessageState?,command: String?,arguments: String?,attachments: [AnyChatMessageAttachment]?,translations: [TranslationLanguage: String]?,originalLanguage: TranslationLanguage?,moderationDetails: MessageModerationDetails?,extraData: [String: RawJSON]?)-> ChatMessage

- public struct ChatMessage: Identifiable, Sendable  
- 
-   public let id: MessageId
-   public let cid: ChannelId?
-   public let text: String
-   public let type: MessageType
-   public let command: String?
-   public let createdAt: Date
-   public let locallyCreatedAt: Date?
-   public let updatedAt: Date
-   public let deletedAt: Date?
-   public let textUpdatedAt: Date?
-   public let arguments: String?
-   public let parentMessageId: MessageId?
-   public let showReplyInChannel: Bool
-   public let replyCount: Int
-   public let extraData: [String: RawJSON]
-   public var quotedMessage: ChatMessage?
-   public let draftReply: DraftMessage?
-   public let reminder: MessageReminderInfo?
-   public let isBounced: Bool
-   public let isSilent: Bool
-   public let isShadowed: Bool
-   public let deletedForMe: Bool
-   public let reactionScores: [MessageReactionType: Int]
-   public let reactionCounts: [MessageReactionType: Int]
-   public let reactionGroups: [MessageReactionType: ChatMessageReactionGroup]
-   public let author: ChatUser
-   public let mentionedUsers: Set<ChatUser>
-   public let mentionedHere: Bool
-   public let mentionedChannel: Bool
-   public let mentionedGroups: Set<UserGroupMention>
-   public let mentionedRoles: [String]
-   public let threadParticipants: [ChatUser]
-   public var threadParticipantsCount: Int
-   public var attachmentCounts: [AttachmentType: Int]
-   public let latestReplies: [ChatMessage]
-   public let localState: LocalMessageState?
-   public let isFlaggedByCurrentUser: Bool
-   public let latestReactions: Set<ChatMessageReaction>
-   public let currentUserReactions: Set<ChatMessageReaction>
-   public var currentUserReactionsCount: Int
-   public let isSentByCurrentUser: Bool
-   public let pinDetails: MessagePinDetails?
-   public let translations: [TranslationLanguage: String]?
-   public let originalLanguage: TranslationLanguage?
-   public let moderationDetails: MessageModerationDetails?
-   public let readBy: Set<ChatUser>
-   public var readByCount: Int
-   public let poll: Poll?
-   public let sharedLocation: SharedLocation?
-   public let channelRole: MemberRole?
-   
- 
-   public func translatedText(for language: TranslationLanguage)-> String?
-   public func changing(text: String? = nil,type: MessageType? = nil,state: LocalMessageState? = nil,command: String? = nil,arguments: String? = nil,attachments: [AnyChatMessageAttachment]? = nil,translations: [TranslationLanguage: String]? = nil,originalLanguage: TranslationLanguage? = nil,moderationDetails: MessageModerationDetails? = nil,readBy: Set<ChatUser>? = nil,deletedAt: Date? = nil,extraData: [String: RawJSON]? = nil)-> ChatMessage
-   public func replacing(text: String?,extraData: [String: RawJSON]?,attachments: [AnyChatMessageAttachment]?)-> ChatMessage
-   public func replacing(text: String?,type: MessageType,state: LocalMessageState?,command: String?,arguments: String?,attachments: [AnyChatMessageAttachment]?,translations: [TranslationLanguage: String]?,originalLanguage: TranslationLanguage?,moderationDetails: MessageModerationDetails?,extraData: [String: RawJSON]?)-> ChatMessage

- public struct ChatChannel: Sendable  
+ public struct ChatChannel: Equatable, Sendable  
-   public let cid: ChannelId
+   public var lastActiveWatchersLimit
-   public let name: String?
+   public var lastActiveMembersLimit
-   public let imageURL: URL?
+   public var latestMessagesLimit
-   public let lastMessageAt: Date?
-   public let createdAt: Date
-   public let updatedAt: Date
-   public let deletedAt: Date?
-   public let truncatedAt: Date?
-   public let isHidden: Bool
-   public let createdBy: ChatUser?
-   public let config: ChannelConfig
-   public let filterTags: Set<String>
-   public let ownCapabilities: Set<ChannelCapability>
-   public let isFrozen: Bool
-   public let isDisabled: Bool
-   public let isBlocked: Bool
-   public let memberCount: Int
-   public let messageCount: Int?
-   public let lastActiveMembers: [ChatChannelMember]
-   public let currentlyTypingUsers: Set<ChatUser>
-   public internal var membership: ChatChannelMember?
-   public var isArchived: Bool
-   public var isPinned: Bool
-   public let lastActiveWatchers: [ChatUser]
-   public let watcherCount: Int
-   public let team: TeamId?
-   public let unreadCount: ChannelUnreadCount
-   public let latestMessages: [ChatMessage]
-   public let lastMessageFromCurrentUser: ChatMessage?
-   public let pinnedMessages: [ChatMessage]
-   public let pendingMessages: [ChatMessage]
-   public let reads: [ChatChannelRead]
-   public let muteDetails: MuteDetails?
-   public var isMuted: Bool
-   public let cooldownDuration: Int
-   public let extraData: [String: RawJSON]
-   public let draftMessage: DraftMessage?
-   public let activeLiveLocations: [SharedLocation]
-   public let pushPreference: PushPreference?
-   
- 
-   public func replacing(name: String?,imageURL: URL?,extraData: [String: RawJSON]?)-> ChatChannel
-   public func changing(name: String? = nil,imageURL: URL? = nil,lastMessageAt: Date? = nil,createdAt: Date? = nil,deletedAt: Date? = nil,updatedAt: Date? = nil,truncatedAt: Date? = nil,isHidden: Bool? = nil,createdBy: ChatUser? = nil,config: ChannelConfig? = nil,filterTags: Set<String>? = nil,ownCapabilities: Set<ChannelCapability>? = nil,isFrozen: Bool? = nil,isDisabled: Bool? = nil,isBlocked: Bool? = nil,reads: [ChatChannelRead]? = nil,members: [ChatChannelMember]? = nil,membership: ChatChannelMember? = nil,memberCount: Int? = nil,watchers: [ChatUser]? = nil,watcherCount: Int? = nil,team: TeamId? = nil,cooldownDuration: Int? = nil,pinnedMessages: [ChatMessage]? = nil,pushPreference: PushPreference? = nil,currentlyTypingUsers: Set<ChatUser>? = nil,extraData: [String: RawJSON]? = nil)-> ChatChannel

@nuno-vieira
nuno-vieira merged commit 7e702d8 into develop Jul 13, 2026
18 checks passed
@nuno-vieira
nuno-vieira deleted the perf/channel-list-performance-improvements branch July 13, 2026 16:28
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🟢 QAed A PR that was QAed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants