Repository navigation
feat!: replace StreamChat.activeChannels with entity item index for loaded channels - #1901
Merged
MartinCupela merged 62 commits intoOct 9, 2026
Conversation
… class-instance stores EntityStore gains the operations a store of class instances (channels) needs: - onRelease option, called with an entity once its last holder unlinks or on clear() - getOrCreate(id, create, hydrate?) returns the stored instance or stores a new one - changeId(oldId, newId) moves an entry and its holders to a server-assigned id - clear() removes every entry and releases each entity Subscribers can implement onIdChanged to follow a changeId rename. StoreBackedItemIndex links its own holder object per index instead of its owner (or the shared NOOP_OWNER), renames memberIds on onIdChanged and forwards notifications to the owner, so a renamed entity is still released when the index removes it. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rn down A second _disconnect() decremented the receipts tracker's and cooldown timer's subscription counts held by other consumers and re-published channel state. With the upcoming channel store several paths can end the same channel, so _disconnect() now returns early once pendingDisposal is set. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
channel.data reads state.data, and assigning it publishes data, memberCount and ownCapabilities in one state update, so every assignment (including from app code) reaches the state. A partial update that omits member_count or own_capabilities gets the last known value on a copy; the assigned object is no longer changed in place. This replaces the nine ChannelState.syncStateFromChannelData calls, and the method is removed. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read state _updateMemberWatcherReferences edited the member, watcher and read objects in place, so subscribers of channel.state never re-rendered on user.updated or user.presence.changed. It now publishes new objects in one channel.state.partialNext per affected channel. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l.truncated These events only re-published each list's items, so a list sorted or filtered by a field they change (e.g. the name) kept the channel in its old place. They now route the loaded channel through the same filter and ownership logic as the other list events, which calls ingestItem on the owning lists and removes it from lists it no longer matches. Channels that aren't loaded are ignored. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChannelManager owns channelStore, an EntityStore<Channel> keyed by cid that tears a channel down with _disconnect() when it's removed or its last holder leaves, and exposes get(cid) and values(). client.channel() creates channels through it, a channel created from members moves from its temporary cid to the real one as the same instance, and the known ends and disconnectUser remove channels through it. client.activeChannels is kept as a mirror until its readers move to the store. EntityStore gains remove(id) and values(). REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A channel now stays in the channel store while something holds it. The watchStatus setter adds a watch hold on watching and wasWatching, so an interrupted watch can still be restored, and releases it on notWatching. The first activate() adds an activated hold that lasts until logout or a known end. activate() returns a release function that only unsets `active` and does nothing when called again; deactivate() is removed. _disconnect() writes watchStatus directly, not through the setter, so teardown releases no hold and can't re-enter itself through the store. It still publishes pendingDisposal last, after the subscriptions and paginators are torn down, now in one update with watchStatus. BREAKING CHANGE: Channel.deactivate() is removed; call the function returned by channel.activate() instead. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChannelPaginator builds its StoreBackedItemIndex over client.channelManager's channel store (a new internal `store` option), so a channel stays stored while any list lists it and is torn down once nothing holds it. A list releases a channel when ingestItem finds it no longer matches the filter, and releases all on resetState(). ChannelManager.ingestChannel ingests into the owning lists before removing from the others, so a list that was the last holder can't tear a channel down mid-move; routeToPaginators delegates to it. The store's release also drops the channel from the activeChannels mirror. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…store Every reader of client.activeChannels now uses client.channelManager.get(cid) or values(), and the field is removed. The client's own ChannelManager owns the channel store; a second manager for one client is unsupported, so the ChannelManager tests configure client.channelManager instead of building their own. hydrateActiveChannels is renamed to hydrateChannels. The client's _markActiveChannelsWatchInterrupted, _reflectMutedChannelsToActiveChannels and _resetAIStateOnActiveChannels move to ChannelManager as the internal markChannelsWatchInterrupted, reflectMutedChannels and resetAIStateOnChannels. BREAKING CHANGE: client.activeChannels is removed; use client.channelManager.get(cid) and client.channelManager.values(). client.hydrateActiveChannels is renamed to client.hydrateChannels. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntities EntityStore.remove() and clear() now call the new optional EntityStoreSubscriber.onEntityRemoved(id, entity) on every holder of a removed entity, after the holders are dropped but while the entity can still be read. StoreBackedItemIndex passes the item to its owner and drops it from memberIds; ChannelPaginator (now the owner of its index) and MessageIntervalPaginator remove it from their windows. This fixes deleted channels staying in channel lists: ChannelManager handles events asynchronously, after the client has removed the channel from the shared store, so its list handler could no longer find the channel by cid. removeChannel is now a single store.remove(cid), and it also cleans the lists on notification.channel_deleted, which has no list handler. REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mchatactivechannels-with-entity-item-index-for Brings in the thread paginator and ThreadManager revamp (#1888) and the channel paginator merging fixes (#1894). EntityStore keeps #1888's isHeldBy and values(); the new ChannelPaginator test spies on the renamed hydrateChannels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rn-down channel The v9 → v10 guide covers the removal of `client.activeChannels`, the channel store and its holds, the release function returned by `channel.activate()` in place of `deactivate()`, and the `hydrateActiveChannels` → `hydrateChannels` rename. `channel.activate()` reads the client through `_client` rather than `getClient()`, which throws on a torn-down channel. Holding a torn-down channel does nothing, so activating it is now safe; React's `Channel` calls it on mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A thread exists server-side only once its parent message has a reply. `ensure()` marked every thread it built stale, so opening a brand-new thread sent `GET /threads/:id`, which answered 404. It now marks a built thread stale only when the parent reports replies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`toUpdatedMessagePayload` stripped `error` but kept `status` and `reaction_scores`, which the server stores as custom message data. Both are now left out, for the default update operation and the composer's edit path alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nager.ensure()
`client.channelManager.ensure({ type, id?, data? })` returns the stored channel for a cid or
creates it, following `client.threads.ensure()`. `client.channel()`, `client.getChannelById()`
and `client.getChannelByMembers()` are removed; their by-id and by-members logic moved into
`ChannelManager` unchanged. `ensure()` takes no hold: lists, `channel.activate()` and watching do.
Every caller, test, spy and mock moves to `ensure()`. The README, `docs/` and the v9 → v10
migration guides map each `client.channel()` form to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ChannelManager.holdChannel` / `releaseChannel` become `register` / `release`, as on `ThreadManager`. `ThreadManager`'s private `releaseChannel`, which releases a disposed channel's threads, becomes `releaseThreadsOfChannel` so it no longer reads like the channel's own release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…used The channel store no longer tears a channel down when its last hold goes. ChannelManager.releaseUnusedChannels() removes every stored channel that is unwatched and unused, and runs when reload() and recover() finish. Known ends and logout still remove a channel right away. A channel is kept by its own state (watched, active, a channel query in flight) or by a holder in the channel store: a channel list links its channels, while threads, the message composer cache and an active channel search add a named EntityStoreClaim. getChannelUsage() reports what keeps each stored channel, for debugging tools. - EntityStore: releaseOnLastUnlink option, addClaim(), isHeld(), holderNames(), unheldEntries(), entries(); subscribers and claims carry kebab-case names, and a claim listing an entity several times counts once - ChannelManager: ChannelHold, register() and release() are removed - ChannelSearchSource queries with watch: false and claims its results while active; SearchController gains registerSubscriptions(), and dispose() also disposes its sources - the offline guard reads channels with get() instead of ensure() - migration guides: channel.activate() is new in v10, not a v9 API BREAKING CHANGE: an unwatched channel nothing uses is torn down when the lists reload or the connection recovers. Channel search results are not watched; watch a result when it is opened. Refs: REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed from members gets its cid
When the query of a channel created from members (or with neither id nor
members) returns a cid another stored instance already holds, both used to
stay stored. The queried instance now replaces the stored one, because its
caller is using it; the stored one was rarely in use, since ensure() would
have returned it had its members been loaded.
The swap runs once the queried instance's data is set, so a failure earlier in
the query leaves the stored instance in place. It happens in place: the lists
showing the replaced instance stay linked and each swaps the item with one
update, never publishing a state without the conversation. The replaced
instance is torn down through the store's onRelease.
- EntityStore.changeId() takes { replace: true } to move an entity onto an id
another entity holds, releasing that one without telling its holders it was
removed
- EntityStore.replace(entity) stores an entity in place of the one under its
id, for a channel that had no temporary cid
- ChannelManager.replaceChannel() (internal) does the swap and routes the
channel to the lists it matches
Refs: REACT-1062
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pdated Read-receipt avatars and names kept showing a user's old data after user.updated until that user's next read event. Two causes, each enough on its own: - client._updateMemberWatcherReferences() wrote the updated user into channel.state.read without reconcile metadata, and the tracker drops a read-state change that names no changed users. It now names the user, still in the same single state update. - upsertUserProgress() compared users by id, so a new user object with an unchanged read position counted as no change. It now compares by reference. Refs: REACT-1062 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pdated Each vote carries a copy of its voter, and no poll listened to user.updated, so poll option voters kept showing a user's old image and name. PollManager now subscribes to user.updated and calls the new Poll.handleUserUpdated() on every cached poll, which replaces that user's votes in latest_votes_by_option, latest_answers, the own votes and answer, and created_by, in one state update. Other voters' votes keep their references, and a poll without the user publishes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mchatactivechannels-with-entity-item-index-for # Conflicts: # src/client.ts # test/unit/api-client.test.ts # v9-to-v10-migration-guide-other.md
MartinCupela
requested review from
isekovanic,
oliverlaz,
santhoshvai,
szuperaz and
vishalnarkhede
as code owners
October 5, 2026 14:28
oliverlaz
reviewed
Oct 5, 2026
oliverlaz
requested changes
Oct 5, 2026
isekovanic
reviewed
Oct 8, 2026
isekovanic
reviewed
Oct 8, 2026
…channel store While a message search is active its results are on screen, but nothing held the channels they were found in, so `releaseUnusedChannels()` could dispose a channel a result row still opens. The source now claims those channels while it is active, as `ChannelSearchSource` does with its results, and drops the claim when it is deactivated or disposed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`jumpToMessage(id, { watchChannel: true })` also watches a channel that isn't watched yet, with the
same `id_around` request that loads the window around the message, so one request does both and
gets the jump's retries and error notification. A message already loaded is loaded again with that
request. The option is ignored for a thread's reply list and with `doRequest`.
The flag reaches the paginator's `query()` through a new optional `requestOptions` in
`PaginationQueryParams`: request options that are not part of the query shape, so they don't decide
whether a query starts a new first page, and that retries send again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…count may be out of date A thread built from its parent message started stale only when the parent reported replies, so that opening a message to write its first reply sends no request that can only answer 404. But a parent's `reply_count` stays current only while its channel is watched: in a channel stored but not watched it misses the replies sent since, and a thread whose parent wrongly said 0 never loaded them. The thread now also starts stale when its channel isn't watched; in a watched channel a parent without replies still sends no request. The rule moves from `ThreadManager.ensure()` into the `Thread` constructor, so `new Thread()` with a parent message follows it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`channel.ensureWatched(options?)` watches the channel unless it is watched already, without duplicating a request: a call made while an earlier `ensureWatched()` with the same options (compared deeply) is in flight waits for that one. It resolves with the channel, so it can follow `client.channelManager.ensure(...)` in one expression. `watch()` and `query()` are unchanged: they always send their request and are never joined. This replaces the `getChannel()` helper, which deduplicated watches in a module-level map keyed by cid. The channel store keeps one instance per cid, so the instance can track its own watch in flight. The channel manager now uses `ensure().ensureWatched()` for a channel an event brings in and `ensureWatched()` to restore a dropped watch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`channel.disconnect()` stops the instance for good and marks it for disposal (`pendingDisposal`), as the SDK does when a channel ends. It is now public, so an app can finish an instance it is done with. It doesn't remove the channel from the channel store: a stored instance stays there, finished, until the SDK removes it, and `ensure()` gives a fresh instance for its cid meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`channel.disconnect()` finishes an instance but keeps it stored and listed, as parts of the app may still use it. `releaseUnusedChannels()` now also removes every stored channel marked `pendingDisposal`, whatever still holds or uses it, so a disconnected channel leaves the store and every list at the next release. Other channels are released as before: only when neither watched nor held. The search sources' claim tests now check the claim through `getChannelUsage()`, as releasing their channels disconnects them for good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A channel the app disconnected stays stored until `releaseUnusedChannels()`. `dispatchEvent` still passed it every event, and its handlers threw at `getClient()`, so the event never reached the client's listeners or the offline DB. `dispatchEvent` now skips the state handling of a channel marked `pendingDisposal`; the channel's own listeners still hear the event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A channel created from members is stored under a temporary cid until its first query gives it an id. A list that ingested it kept the temporary cid in its intervals when the index renamed it, so every later ingestion or removal of that channel threw and its row froze; superseding it could also remove the wrong row. Channel lists, and `channelManager.ingestChannel()`, now refuse a channel without an id; it is listed once its query gives it one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…perseded one A channel list saved the instance it was handed into the channel store, replacing a different one stored under the same cid. Handed a superseded instance (a channel created from members whose watch found the cid already stored), it left the stored one out of the store, unfinished and without events, and the superseded one was disconnected later under the lists. `ChannelPaginator.ingestItem()` and `ChannelManager.ingestChannel()` now resolve the instance to list: the one that superseded it, else the stored one; a disconnected instance with nothing stored is ignored. `channel.ensureWatched()` also resolves with the instance to use: the successor if the channel was superseded before or during its watch, ensuring that one is watched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a watch's response superseded the instance it was sent for (another instance already stored under the cid), the instance was still marked `Watching`, though events for the cid go to the stored instance, which took over the watch. It is now `NotWatching`. The `supersededBy` docs say that a superseded instance receives no events and that its successor is the one to use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing channels `clearChannels()` empties the store and then rethrows the first error a holder threw, so a throwing holder skipped the rest of `disconnectUser()`: the client state, thread, mute, upload and composer-cache resets, the configuration teardown and the token reset. The error is now logged and the logout finishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eact-1062-replace-streamchatactivechannels-with-entity-item-index-for Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isekovanic
approved these changes
Oct 9, 2026
The app may still hold a superseded instance, so releasing its last activation only marks it inactive. It is disconnected at logout, or when the app calls channel.disconnect(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A disconnected channel stays in the store until the next releaseUnusedChannels(). Until then a query that finds it under its cid takes its place instead of being superseded by it, events for its cid get a live instance through ensure(), and lists refuse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion guides _disconnect() is public as disconnect(), and a disconnected channel counts as not stored until releaseUnusedChannels() drops it. ensureWatched() replaces the internal getChannel() helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h results
A message search result is a preview, as a channel search result is. The channels fetched for
results the client doesn't have are now queried with watch: false unless channelQueryOptions ask
for it; opening a result watches its channel.
BREAKING CHANGE: MessageSearchSource no longer watches the channels it fetches for its results;
watch the channel when a result is opened, e.g. with jumpToMessage(id, { watchChannel: true }).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a query without a watch supersedeChannel() passed the query's watch flag on as undefined when the query didn't ask to watch, and hydrateChannels() takes a missing flag as watched while connected. The stored instance is now marked watched only when the query asked to watch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d recover() directly Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s channel query doesn't return The follow-up query leaves out hidden channels and those its filters exclude, so their results had no stored channel and UIs rendered no row for them. Such a channel is now stored from the channel data its result carries, unwatched and not loaded until opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….disconnected The migration guide covers v9's channel.disconnected becoming the read-only, reactive channel.pendingDisposal. The getter's JSDoc says a disconnected channel stays stored until releaseUnusedChannels(), and the lifecycle state comment no longer mentions a setter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MartinCupela
deleted the
martincupela/react-1062-replace-streamchatactivechannels-with-entity-item-index-for
branch
October 9, 2026 12:42
github-actions Bot
pushed a commit
that referenced
this pull request
Oct 9, 2026
## [10.0.0-rc.20](v10.0.0-rc.19...v10.0.0-rc.20) (2026-10-09) ### ⚠ BREAKING CHANGES * client.activeChannels is removed; use client.channelManager.get(cid) and client.channelManager.values(). * client.hydrateActiveChannels() is renamed to client.hydrateChannels(). * client.getChannelById() and client.getChannelByMembers() are removed; use client.channel() or client.channelManager.ensure({ type, id?, data? }). * thread.deactivate() is removed; thread.activate() returns the function that ends the activation. * channel._disconnect() is renamed to channel.disconnect(). * channel.disconnected is removed; read the read-only channel.pendingDisposal instead, which only channel.disconnect() sets. * when a channel created from members gets a cid another stored instance already holds, the stored instance stays, as in v9, and the new one is marked channel.supersededBy; if the new one is open and the stored one isn't, its unsent composition moves to the stored one, so UIs holding the new instance should switch to channel.supersededBy. * channel.initialized is set by query() and create() too, not only by watch(). * channel search results are not watched; watch a result when it is opened. * MessageSearchSource no longer watches the channels it fetches for its results; watch the channel when a result is opened, e.g. with jumpToMessage(id, { watchChannel: true }). ### Features * replace StreamChat.activeChannels with entity item index for loaded channels ([#1901](#1901)) ([ec9178a](ec9178a))
|
🎉 This PR is included in version 10.0.0-rc.20 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description of the changes, What, Why and How?
Linear: REACT-1062. Design: How stream-chat-js organizes and manages data. This PR implements the channel part of that design; users, messages and relations (its "Nice to have" section) are out of scope. Companion PR: GetStream/stream-chat-react#3310.
Why
client.activeChannelswas a plain object keyed by cid that only grew: a channel left it only on deletion, removal or logout, every lookup built its own instance when it missed, and nothing guaranteed oneChannelper cid. Channel lists (ChannelPaginator) kept their own references, so a channel could be disconnected while a list still showed it, or stay alive after nothing used it.What
One
Channelper cid, in a channel store.ChannelManagerownschannelStore, a class-instanceEntityStore<Channel>keyed by cid, the wayThreadManagerowns its thread store. It replacesclient.activeChannels, which is removed. Read channels withclient.channelManager.get(cid)andclient.channelManager.values(), and get or create one withclient.channelManager.ensure({ type, id?, data? }).client.channel()stays, as a shorthand forensure()with the same argument forms as in v9. Each channel's data stays in its ownchannel.state.EntityStoreadditions for class instances. Stores that don't use them behave as before:getOrCreate(id, create, hydrate?)returns the stored instance (passing it tohydrate) or stores the result ofcreate; a stored instance is never replaced, so every caller gets the same one.onRelease(entity)runs when an entry is removed; the channel store uses it to callchannel.disconnect().changeId(oldId, newId)moves an entry and its holders when a channel created from members gets its real cid.replace(entity),remove(id),detach(id)(drops an entry without releasing it),clear(),values(),entries().onEntityRemoved(id, entity)lets each list drop a removed channel itself, so after a deletion or logout no list shows a disconnected channel.remove()andclear()finish even when a holder throws.releaseOnLastUnlink: falsekeeps entries when their last link goes; the channel store releases them only when asked (below).Lists hold their channels in the store.
ChannelPaginatorbuilds itsStoreBackedItemIndexover the channel store (astoreoption, defaulting toclient.channelManager's) and links each channel on its loaded pages. Every index is its own holder, named after its paginator.A channel stays stored until it ends, the user logs out, or the app releases it. As in v9, the SDK never releases a channel on its own: it can't see the channels an app keeps in its own state. Known ends (deletion, removal from the channel,
notification.channel_deleted) anddisconnectUser()remove and disconnect channels right away. To free memory, the app callsclient.channelManager.releaseUnusedChannels(), which:channel.disconnect(), sopendingDisposal), whatever still holds it;What keeps a channel:
watching, orwasWatchinguntil the watch is restored), active (channel.activate()not yet released), or awatch()/query()/create()in flight;channelStore.addClaim({ name, heldBy }), which the store asks when it needs to know.getChannelUsage()reports what keeps each channel, for debugging tools.Disconnecting a channel.
channel.disconnect()(public; it was_disconnect()) stops the instance for good: it stops listening to the client's configuration, releases its loaded messages, and setspendingDisposal: trueandwatchStatus: NotWatching.channel.pendingDisposalreplaces v9'schannel.disconnected: it is read-only (onlydisconnect()sets it, and nothing sets it back) and lives inchannel.state, so it can be followed reactively. It sends no request and doesn't remove the channel from the store, as something may still show it; the nextreleaseUnusedChannels()does. A disconnected channel throws fromgetClient(), itsquery()andwatch()reject before the request is sent,activate()warns and does nothing, and the client skips its state handling for events (the channel's own listeners still hear them). UntilreleaseUnusedChannels()drops it, the SDK treats it as not stored: lists refuse it, an event for its cid gets a live instance throughensure(), and a query that finds it under its cid takes its place instead of being superseded by it. Callingdisconnect()again does nothing.Channels created from members are provisional.
ensure({ type, data: { members } })builds a channel without an id (channel.isProvisional), stored under a temporary cid built from the sorted member ids (type:!members-ann,bob), which is also itschannel.cid. Until its query is answered, no other request can be sent for it: typing events and drafts are skipped, any other request throws before it is sent, and nothing is queued for offline replay. Channel lists refuse it until then. The query's response gives it the real id, and the same instance moves to the real cid.Superseding. If another instance was stored under the real cid meanwhile (by an event or a list), that stored instance stays the one for the cid, as in v9, and takes over the response, the new instance's local messages and, when the new instance is open and the stored one isn't, its composer (
messageComposer.transferTo()). The stored instance is marked watched only when that query asked to watch: aquery()orcreate()withoutwatch: trueleaves its watch status as it was. The new instance is markedchannel.supersededBy(reactive), reportsNotWatching, and receives no events. The SDK never disconnects it before logout, as the app may still hold it; the app may calldisconnect()on it. Lists never ingest a superseded instance (they take its successor), andensureWatched()resolves with the successor.channel.ensureWatched(options?)watches a channel unless it is already watched, and joins a watch already in flight with equal options instead of sending a second request. It resolves with the channel to use: the successor if the channel was superseded. It replaces the internalgetChannel()helper; the channel manager uses it for a channel an event brings in and to restore a dropped watch.watch()andquery()still always send their request.Keeping the store consistent.
channel.datais a getter and setter overchannel.state.data, so every server payload reaches subscribers.channel.updatedandchannel.truncatedre-insert the channel into its lists, since a channel's sort values change in place.channel.initializedis set by any query, soquery()andcreate()set it aswatch()does.User updates reach what shows them. On
user.updated, the client publishes new member, watcher and read objects in onechannel.stateupdate (including members and watchers that joined after the query); the read-receipts tracker and poll votes take the updated user too. A presence change no longer rebuilds read receipts or re-renders channel lists.How
watch: false: a result is a preview, and opening it watches it. Message search fetches the channels of its results withwatch: falsetoo (unlesschannelQueryOptionsask for it), and opening a result watches its channel throughjumpToMessage(id, { watchChannel: true }).SearchControllergainsregisterSubscriptions(), anddispose()also disposes its sources, so an active search's claim on its results ends with the search. Message search stores every result's channel: those its channel query doesn't return (hidden channels, or oneschannelQueryFiltersexclude) are stored from the channel data each result carries, unwatched and not loaded until opened. It keeps the channels of its active results in the store.channel.activate()andthread.activate()return the function that ends the activation; each call gets its own. (channel.activate()is new in v10.)client.hydrateActiveChannels()is renamed toclient.hydrateChannels().BasePaginatorquery params takerequestOptionspassed toquery(), andmessagePaginator.jumpToMessage(id, { watchChannel: true })loads the window around a message and watches the channel in one request.dispatchEventisolates each post-listener callback, so one that throws doesn't stop the offline DB write.ChannelManagerfor one client is unsupported: the client's own manager owns the channel store.Breaking changes
Against v9:
BREAKING CHANGE: client.activeChannels is removed; use client.channelManager.get(cid) and client.channelManager.values().
BREAKING CHANGE: client.hydrateActiveChannels() is renamed to client.hydrateChannels().
BREAKING CHANGE: client.getChannelById() and client.getChannelByMembers() are removed; use client.channel() or client.channelManager.ensure({ type, id?, data? }).
BREAKING CHANGE: thread.deactivate() is removed; thread.activate() returns the function that ends the activation.
BREAKING CHANGE: channel._disconnect() is renamed to channel.disconnect().
BREAKING CHANGE: channel.disconnected is removed; read the read-only channel.pendingDisposal instead, which only channel.disconnect() sets.
BREAKING CHANGE: when a channel created from members gets a cid another stored instance already holds, the stored instance stays, as in v9, and the new one is marked channel.supersededBy; if the new one is open and the stored one isn't, its unsent composition moves to the stored one, so UIs holding the new instance should switch to channel.supersededBy.
BREAKING CHANGE: channel.initialized is set by query() and create() too, not only by watch().
BREAKING CHANGE: channel search results are not watched; watch a result when it is opened.
BREAKING CHANGE: MessageSearchSource no longer watches the channels it fetches for its results; watch the channel when a result is opened, e.g. with jumpToMessage(id, { watchChannel: true }).
Both migration guides (
v9-to-v10-migration-guide-other.md,-methods.md) cover these, with a checklist item.Changes for apps on an earlier v10 release candidate
Not breaking against v9, as v9 had neither API:
channel.deactivate()is removed; call the function returned bychannel.activate()instead.channel.pendingDisposalsetter is removed; onlychannel.disconnect()sets it.Also in this PR
ThreadManager.ensure()marks a new thread stale only when its parent message has replies, so opening a brand-new thread no longer requests it (404). A thread whose channel isn't watched still loads, as its reply count may be out of date.statusandreaction_scoresinto its custom data.toUpdatedMessagePayload()now treatsstatusas a local field andreaction_scoresas a reserved one, for the defaultupdateMessageoperation and the composer's edit path alike.MessagePaginatorseeds a page into a list loaded empty instead of dropping it.ChannelPaginatormatches fields kept underdata.custom, the own user via membership, and the type, id and cid of a channel not yet queried; it sorts and filters disconnected channels without callinggetClient().disconnectUser()empties the channel lists in one update and finishes even when a holder throws while the channels are cleared.E2E scenarios tested
Run by hand in
examples/vite(stream-chat-react v15 linked to this branch) against a real app. Each check readclient.channelManager.getChannelUsage()(what keeps each channel) and the instance identity. The run predates the switch to explicit release: where a row says "the release after recovery", the same check now holds for an explicitreleaseUnusedChannels()call. Deletions and removals were dispatched as local events; slow and failing queries came from an axios interceptor; a channel kept by a single reason was isolated by stopping its watch and taking it off the lists. Archive and mute were reversed right after.watched, channel-paginator(+activeon the open one); a release removed nothingchannel-paginatornotWatching | channel-paginator, survived a releasependingDisposal) and removed at the next releasewasWatching, all kept bywatchedwasWatching; after reconnect, watched again and the unused channel releasedactivealonewatched, active, channel-paginatorremoveItemchannel-paginatoreachnotification.added_to_channelfetched it:watched, channel-paginatorthreadsalonethreads; a reload dropping a thread not runmessage-composer-cachealone, also after cancelling the editwatch: false; resultsnotWatching, channel-searchwatched, channel-paginator, channel-search, each oncechannel-searchgone; unopened results released at the next releasewatchingandactivechannel-searchclaim after a fresh loadquerying-channel, survived a release, thenwatchednotification.channel_deletedactive, querying-channel, survived a release, moved to the real cid as the same instanceFound during the run and fixed in this PR:
holderNames()repeated a claim's name once per listed entity (the threads claim lists a channel once per thread).Also checked in the app with a local
user.updated: the read-receipt tooltip, the poll option voters and the channel list previews show the user's new name and image, and only the previews of channels containing that user change.Changelog
client.activeChannelswith a channel store onclient.channelManager(get,values,ensure);client.channel()is a shorthand forensure(), andgetChannelById()/getChannelByMembers()are removed.client.channelManager.releaseUnusedChannels()to release channels that are neither watched nor used, and channels the app disconnected.channel.disconnect()public (was_disconnect()), and replacechannel.disconnectedwith the read-only, reactivechannel.pendingDisposal.channel.ensureWatched(), which joins a watch already in flight and resolves with the channel to use.channel.supersededBy).thread.activate()returns a release;thread.deactivate()is removed.watchChanneltomessagePaginator.jumpToMessage().EntityStore:getOrCreate,onRelease,changeId,replace,remove,detach,clear, claims (addClaim) and removal notifications for holders.user.updated.