Skip to content

feat!: replace StreamChat.activeChannels with entity item index for loaded channels - #1901

Merged
MartinCupela merged 62 commits into
release-v10from
martincupela/react-1062-replace-streamchatactivechannels-with-entity-item-index-for
Oct 9, 2026
Merged

MartinCupela merged 62 commits into
release-v10from
martincupela/react-1062-replace-streamchatactivechannels-with-entity-item-index-for

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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.activeChannels was 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 one Channel per 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 Channel per cid, in a channel store. ChannelManager owns channelStore, a class-instance EntityStore<Channel> keyed by cid, the way ThreadManager owns its thread store. It replaces client.activeChannels, which is removed. Read channels with client.channelManager.get(cid) and client.channelManager.values(), and get or create one with client.channelManager.ensure({ type, id?, data? }). client.channel() stays, as a shorthand for ensure() with the same argument forms as in v9. Each channel's data stays in its own channel.state.

EntityStore additions for class instances. Stores that don't use them behave as before:

  • getOrCreate(id, create, hydrate?) returns the stored instance (passing it to hydrate) or stores the result of create; 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 call channel.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().
  • Removals reach the holders: onEntityRemoved(id, entity) lets each list drop a removed channel itself, so after a deletion or logout no list shows a disconnected channel. remove() and clear() finish even when a holder throws.
  • releaseOnLastUnlink: false keeps entries when their last link goes; the channel store releases them only when asked (below).

Lists hold their channels in the store. ChannelPaginator builds its StoreBackedItemIndex over the channel store (a store option, defaulting to client.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) and disconnectUser() remove and disconnect channels right away. To free memory, the app calls client.channelManager.releaseUnusedChannels(), which:

  1. removes every stored channel the app disconnected (channel.disconnect(), so pendingDisposal), whatever still holds it;
  2. then removes and disconnects every stored channel that is neither watched nor used.

What keeps a channel:

  • its own state: watched (watching, or wasWatching until the watch is restored), active (channel.activate() not yet released), or a watch() / query() / create() in flight;
  • holders in the store: a channel list links its channels (it needs the store's removal and cid-change notifications), while threads, the message composer cache and an active channel or message search each add one named claim, 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 sets pendingDisposal: true and watchStatus: NotWatching. channel.pendingDisposal replaces v9's channel.disconnected: it is read-only (only disconnect() sets it, and nothing sets it back) and lives in channel.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 next releaseUnusedChannels() does. A disconnected channel throws from getClient(), its query() and watch() 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). Until releaseUnusedChannels() drops it, the SDK treats it as not stored: lists refuse it, an event for its cid gets a live instance through ensure(), and a query that finds it under its cid takes its place instead of being superseded by it. Calling disconnect() 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 its channel.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: a query() or create() without watch: true leaves its watch status as it was. The new instance is marked channel.supersededBy (reactive), reports NotWatching, and receives no events. The SDK never disconnects it before logout, as the app may still hold it; the app may call disconnect() on it. Lists never ingest a superseded instance (they take its successor), and ensureWatched() 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 internal getChannel() helper; the channel manager uses it for a channel an event brings in and to restore a dropped watch. watch() and query() still always send their request.

Keeping the store consistent.

  • channel.data is a getter and setter over channel.state.data, so every server payload reaches subscribers.
  • channel.updated and channel.truncated re-insert the channel into its lists, since a channel's sort values change in place.
  • channel.initialized is set by any query, so query() and create() set it as watch() does.
  • A listener that gets a channel again while its end event is handled keeps the stored instance.

User updates reach what shows them. On user.updated, the client publishes new member, watcher and read objects in one channel.state update (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

  • Channel search queries with watch: false: a result is a preview, and opening it watches it. Message search fetches the channels of its results with watch: false too (unless channelQueryOptions ask for it), and opening a result watches its channel through jumpToMessage(id, { watchChannel: true }). SearchController gains registerSubscriptions(), and dispose() 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 ones channelQueryFilters exclude) 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() and thread.activate() return the function that ends the activation; each call gets its own. (channel.activate() is new in v10.)
  • client.hydrateActiveChannels() is renamed to client.hydrateChannels().
  • BasePaginator query params take requestOptions passed to query(), and messagePaginator.jumpToMessage(id, { watchChannel: true }) loads the window around a message and watches the channel in one request.
  • dispatchEvent isolates each post-listener callback, so one that throws doesn't stop the offline DB write.
  • A second ChannelManager for 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 by channel.activate() instead.
  • The channel.pendingDisposal setter is removed; only channel.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.
  • Editing a message no longer writes status and reaction_scores into its custom data. toUpdatedMessagePayload() now treats status as a local field and reaction_scores as a reserved one, for the default updateMessage operation and the composer's edit path alike.
  • MessagePaginator seeds a page into a list loaded empty instead of dropping it.
  • ChannelPaginator matches fields kept under data.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 calling getClient().
  • 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 read client.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 explicit releaseUnusedChannels() 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.

# Scenario Result Evidence
A1 First load ✅ 20 channels, all watched, channel-paginator (+ active on the open one); a release removed nothing
A2 Switch lists ✅ Archived loaded 2 channels, held by channel-paginator
A3 Load more pages ✅ Default list grew to 20, then 50; every channel kept
B4 Unwatched but listed ✅ notWatching | channel-paginator, survived a release
B5 Unwatched and unused ✅ Torn down (pendingDisposal) and removed at the next release
B6 Interrupted watch ✅ 25 channels went wasWatching, all kept by watched
B7 Recovery ✅ Watches restored; the release after recovery removed the one unused channel
B8 Network offline / online ✅ Real socket close: all wasWatching; after reconnect, watched again and the unused channel released
C9 Active, unwatched, unlisted ✅ Kept by active alone
C10 Close it ✅ Nothing kept it; released at the next release
C11 Reopen a released channel ✅ New instance, old one disposed, renders without errors
C12 Reopen a kept channel ✅ Same instance, loaded messages kept
C13 Restore a channel from the URL ✅ Renders; kept by watched, active, channel-paginator
D14 Leaves its list ⚠️ partial Moving to another list works (D15, D18); "no other list" can't happen in this app, covered with removeItem
D15 Archive / unarchive ✅ Moved to Archived and back as the same instance, never torn down
D16 Channel in two lists ✅ Same instance in default and Unread, one channel-paginator each
D17 Event for an unloaded channel ✅ notification.added_to_channel fetched it: watched, channel-paginator
D18 Mute / unmute ✅ Moved to Muted and back as the same instance
E19 Opened thread ✅ Unwatched, unlisted, closed channel kept by threads alone
E20 Thread list ⚠️ partial Channels of 42 loaded threads show threads; a reload dropping a thread not run
F21 Inline edit composer ✅ Kept by message-composer-cache alone, also after cancelling the edit
F22 Submit an edit after leaving ⏭ not run Would change a real message
G23 Search results are previews ✅ Request sent watch: false; results notWatching, channel-search
G24 Listed search result ✅ watched, channel-paginator, channel-search, each once
G25 Close the search ✅ channel-search gone; unopened results released at the next release
G26 Open a search result ✅ Same instance, now watching and active
G27 StrictMode remount ✅ Exactly one channel-search claim after a fresh load
H28 Query in flight ✅ Delayed query: querying-channel, survived a release, then watched
H29 Failed query ✅ After the error nothing kept it; released
I30 Deleted while open ✅ Removed at once, dropped from its list, no errors
I31 Removed from the channel ✅ Removed at once
I32 notification.channel_deleted ✅ Removed at once
I33 Logout ✅ 53 → 0 stored, all disposed, lists reset
J34 Channel created from members ✅ Temporary key while active, querying-channel, survived a release, moved to the real cid as the same instance
K35 Several holders ⚠️ partial Each holder kept a channel on its own (B4, C9, E19, F21, G25); combined removal sequence not run
K36 Quick switching ✅ 8 fast switches: one active channel, matching the URL, no duplicates
K37 Reconnects while scrolling ✅ 3 offline/online cycles with page loads: no duplicate cid, no torn-down channel in any list

Found 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

  • Replace client.activeChannels with a channel store on client.channelManager (get, values, ensure); client.channel() is a shorthand for ensure(), and getChannelById() / getChannelByMembers() are removed.
  • Add client.channelManager.releaseUnusedChannels() to release channels that are neither watched nor used, and channels the app disconnected.
  • Make channel.disconnect() public (was _disconnect()), and replace channel.disconnected with the read-only, reactive channel.pendingDisposal.
  • Add channel.ensureWatched(), which joins a watch already in flight and resolves with the channel to use.
  • Channels created from members are provisional until their query is answered, and a stored instance supersedes one created meanwhile (channel.supersededBy).
  • Channel search results, and the channels message search fetches for its results, are no longer watched; message search stores every result's channel, also those its channel query doesn't return.
  • thread.activate() returns a release; thread.deactivate() is removed.
  • Add watchChannel to messagePaginator.jumpToMessage().
  • EntityStore: getOrCreate, onRelease, changeId, replace, remove, detach, clear, claims (addClaim) and removal notifications for holders.
  • Fix read receipts, poll voters, members and watchers keeping a user's old name and image after user.updated.

MartinCupela and others added 21 commits September 30, 2026 13:33
… 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 MartinCupela changed the title Martincupela/react 1062 replace streamchatactivechannels with entity item index for feat!: replace StreamChat.activeChannels with entity item index for loaded channels Oct 5, 2026
Comment thread src/client.ts
Comment thread src/entityStore/StoreBackedItemIndex.ts
Comment thread src/client.ts Outdated
MartinCupela and others added 12 commits October 8, 2026 15:08
…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>
MartinCupela and others added 8 commits October 9, 2026 12:26
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
MartinCupela merged commit ec9178a into release-v10 Oct 9, 2026
8 checks passed
@MartinCupela
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))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 10.0.0-rc.20 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants