Skip to content

feat: channel state migration - #1834

Open
isekovanic wants to merge 29 commits into
release-v10from
feat/channel-state-migration
Open

feat: channel state migration#1834
isekovanic wants to merge 29 commits into
release-v10from
feat/channel-state-migration

Conversation

@isekovanic

@isekovanic isekovanic commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

CLA

  • I have signed the Stream CLA (required).
  • Code changes are tested

Description of the changes, What, Why and How?

What

Converges a channel's per-domain reactive stores into one reactive state — channel.state, a StateStore<ChannelStateData> — subscribed to exactly like thread.state:

useStateStore(channel.state, (s) => ({ read: s.read }));

The per-domain *Store handles (readStore, typingStore, membersStore, watcherStore, ownCapabilitiesStore) are removed in favour of this single store, and the state surface is extended with the channel-level slices the UI SDKs previously hand-rolled in React (data, membership, muteStatus, lifecycle flags, aiState, active). messagePaginator and friends stay separate.

This targets release-v10.

Why

Consumers had to know which of ~6 sub-stores held a given field and subscribe to each individually; channel-level state (mute status, membership, AI indicator) lived outside the reactive system entirely, forcing every UI SDK to re-derive it with bespoke client.on(...) listeners and local React state. One flat, reactive channel.state lets a consumer subscribe to any slice through a single selector and deletes that per-SDK plumbing.

How

ChannelState now extends StateStore<ChannelStateData> (not composition — the store's protected members mean a wrapper isn't assignable to StateStore<T>, so useStateStore(channel.state, …) wouldn't typecheck otherwise). ChannelStateData is flat, with the same top-level keys the old per-store shapes used, so existing selectors stay contravariantly assignable and do not need retyping:

type ChannelStateData = WatcherState &     // watchers, watcher_count
  TypingUsersState &                       // typing
  ReadState &                              // read
  MembersState &                           // members, member_count
  MembershipState &                        // membership             (new)
  OwnCapabilitiesState &                   // ownCapabilities
  ChannelDataState &                       // data                   (new)
  MuteStatusState &                        // muteStatus             (new)
  ChannelLifecycleState &                  // initialized/offlineMode/pendingDisposal (new)
  ChannelActivationState &                 // active                 (new)
  AIIndicatorState;                        // aiState                (new)

Convenience getters/setters (channel.state.members, .read, .typing, .watchers, .member_count, .watcher_count) are kept and now proxy the single store; all writes go through partialNext, so a single-key write never wipes sibling slices.

Touched: src/channel_state.ts (the unified store + slices), src/channel.ts (active/activate()/deactivate(), _syncMuteStatus, _setOwnUnreadCount, AI event handling + connection-loss reset), src/client.ts (_reflectMutedChannelsToActiveChannels, _resetAIStateOnActiveChannels, re-seed guard), src/messageDelivery/MessageReceiptsTracker.ts (subscribe via subscribeWithSelector over the read slice), src/types.ts (AIState/AIStates).

Breaking vs v9

Before (v9) After Notes
channel.disconnected channel.pendingDisposal The flag is one-way and terminal — _disconnect() disposes the paginators, unregisters the subscriptions, and the client drops the channel from activeChannels right after; the instance is never reconnected, which the old name implied. The old name is removed outright (no deprecation alias — v10 is a major, so the rename lands in one step). Rename every read/write, and note the state slice key changed with it: (s) => ({ pendingDisposal: s.pendingDisposal }).
AIState incl. AI_STATE_CHECKING_SOURCES reconciled to AI_STATE_EXTERNAL_SOURCES, plus AI_STATE_IDLE / AI_STATE_STOP; canonical AIStates const now exported Soft — (string & {}) keeps arbitrary strings assignable.

Changes relative to earlier v10 pre-releases (not v9 → v10 breaking)

None of these ever shipped in v9, so they are listed for anyone tracking release-v10 rather than as migration steps:

Earlier v10 pre-release Now
useStateStore(channel.state.readStore | typingStore | membersStore | watcherStore | ownCapabilitiesStore, sel) useStateStore(channel.state, sel) — drop the .<X>Store, keep the selector verbatim
channel.state.mutedUsersStore client.mutedUsersStore (muted users are client-global)
MutedUsersState type removed
in-place channel.data.member_count = n / .own_capabilities = [...] synced to state no longer syncs — reassign channel.data = { ...channel.data, member_count: n } (the Object.defineProperty accessors are gone)

Additive

  • channel.state — the single unified StateStore<ChannelStateData>.
  • New reactive slices: data (reactive channel data POJO), membership, muteStatus ({ muted, createdAt, expiresAt }, from client.mutedChannels), initialized / offlineMode / pendingDisposal, aiState, active.
  • channel.activate() / channel.deactivate() / channel.active — refcount-backed "a consumer is currently consuming this channel" flag (a Channel instance is shared across preview + open + threads, so it's refcounted, not last-writer-wins). Gates the list-hydrate re-seed suppression on reconnect. Deliberately carries no rendering semantics — the type is ChannelActivationState, not a UI state.
  • AIStates const exported from the package.

Behavioral

  • AI indicator is now LLC-owned and reactive. Driven from ai_indicator.update / .clear / .stop in _handleChannelEvent (.stop is newly honoured). It auto-resets to Idle on connection loss — transient/internet drop via the health-gated channel cleaning sweep (clean()), deliberate close (e.g. mobile backgrounding) via client.closeConnection() — so a stuck "Generating" can't outlive a lost socket. WS-driven only (no optimistic self-update); ephemeral ai_indicator.* events are not replayed on reconnect by design.
  • muteStatus publishes only on real change (recomputed from client.mutedChannels; no churn on the frequent health.check fan-out) via client._reflectMutedChannelsToActiveChannels().
  • The own unread count now lives in exactly one place. channel.state.unreadCount was a plain (non-store) field kept in parallel with read[ownUserId].unread_messages; it is now a derived getter over that read slice, so the number channel.countUnread() returns can no longer drift from the number an unread badge reads. Consequence worth knowing: the own read row now carries the own-unread gating that only the counter had before — a message that isn't unread-worthy (silent / shadowed / from a muted user / muted channel) no longer bumps it, and neither does one arriving while messagePaginator.isViewingLive (the consumer is looking at the newest message and is about to mark it read). The row is seeded when a channel has none yet (never queried, or query({ watch: false, state: false })) so the count still accumulates there.
  • unreadCount / read[me] kept consistent on channel-wide resetschannel.truncated and "all channels read" route through _setOwnUnreadCount, which writes the read row (guarded; only reconciles an existing entry).
  • Reconnect re-seed of an active channel is suppressed — the list-hydrate seed is gated on !c.active (only in hydrateActiveChannels, never on channel.query).
  • Teardown ordering hardened in _disconnect (subscriptions down before flipping pendingDisposal, since store writes during teardown would trip a getClient()-reading subscription).

Kept deliberately separate

messagePaginator (+ unreadStateSnapshot / live-view state), pinnedMessagesPaginator, messageComposer, and channel.configState.

Review follow-ups (addressed)

  • Channel._syncStateFromChannelData wrapper removed — every call site already passed both arguments, so its default duplicated ChannelState.syncStateFromChannelData's own.
  • ChannelUIStateChannelActivationState, and the active / activate() / deactivate() docs no longer describe UI ("mounted", "on-screen") — the client stays headless.
  • ChannelState.unreadCount field → derived getter (single source of truth, see Behavioral above).
  • isDirectChannel slice dropped — classifying 1:1 by memberCount === 2 is an opinionated definition (products that treat a DM as a creation-time property would disagree, and a 4 → 3 → 2 group would flip). Consumers derive memberCount === 2 themselves at no extra cost: memberCount publishes on the same rare events the slice did.
  • channel.disconnectedchannel.pendingDisposal, old name removed outright rather than deprecated (see Breaking vs v9).
  • currentReadStoreStatecurrentState in _patchReadState; leftover TODO #29 references removed.

Testing

Full suite green: 2740 passed / 1 todo; yarn types clean; yarn lint clean; dist builds. Coverage added in test/unit/channel_state.test.js, channel.test.js, client.test.js, and messageDelivery/MessageReceiptsTracker.test.ts (handle-identity asserts dropped; sibling-preservation, subscribe-by-selector, the derived unreadCount, and the lifecycle / mute / AI / active slices covered).

Comment thread src/channel.ts Outdated
Comment thread src/channel_state.ts
Comment thread src/client.ts Outdated
Comment thread src/channel_state.ts Outdated
Comment thread src/channel_state.ts Outdated
@MartinCupela

MartinCupela commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

In the PR description there is the following:

  • [Breaking] Removed the per-domain channel.state.*Store handles (readStore, typingStore, membersStore, watcherStore, ownCapabilitiesStore); subscribe via useStateStore(channel.state, selector) instead.
  • [Breaking] Removed channel.state.mutedUsersStore and the MutedUsersState type; use the client-global client.mutedUsersStore.
  • [Breaking] In-place mutation of channel.data.member_count / channel.data.own_capabilities no longer syncs to state; reassign channel.data.

We should not mark this change as breaking as these have never made it to v9 (master).

@isekovanic

Copy link
Copy Markdown
Contributor Author

In the PR description there is the following:

  • [Breaking] Removed the per-domain channel.state.*Store handles (readStore, typingStore, membersStore, watcherStore, ownCapabilitiesStore); subscribe via useStateStore(channel.state, selector) instead.
  • [Breaking] Removed channel.state.mutedUsersStore and the MutedUsersState type; use the client-global client.mutedUsersStore.
  • [Breaking] In-place mutation of channel.data.member_count / channel.data.own_capabilities no longer syncs to state; reassign channel.data.

We should not mark this change as breaking as these have never made it to v9 (master).

it's breaking in terms of V10 of course and it can be useful if someone pulls stuff in so that they know what's changed, since V10 is anyway going to be squashed at the end (probably) I can't see why it matters

Comment thread src/channel.ts
@MartinCupela

Copy link
Copy Markdown
Contributor

In the PR description there is the following:

  • [Breaking] Removed the per-domain channel.state.*Store handles (readStore, typingStore, membersStore, watcherStore, ownCapabilitiesStore); subscribe via useStateStore(channel.state, selector) instead.
  • [Breaking] Removed channel.state.mutedUsersStore and the MutedUsersState type; use the client-global client.mutedUsersStore.
  • [Breaking] In-place mutation of channel.data.member_count / channel.data.own_capabilities no longer syncs to state; reassign channel.data.

We should not mark this change as breaking as these have never made it to v9 (master).

it's breaking in terms of V10 of course and it can be useful if someone pulls stuff in so that they know what's changed, since V10 is anyway going to be squashed at the end (probably) I can't see why it matters

Because as an integrator you are looking at a diff v9 - v10. If we say there is a breaking change that is actually not a breaking change, it is misleading. I would just not put these into the description of the commit that will be created with this PR being merged.

@isekovanic

Copy link
Copy Markdown
Contributor Author

In the PR description there is the following:

  • [Breaking] Removed the per-domain channel.state.*Store handles (readStore, typingStore, membersStore, watcherStore, ownCapabilitiesStore); subscribe via useStateStore(channel.state, selector) instead.
  • [Breaking] Removed channel.state.mutedUsersStore and the MutedUsersState type; use the client-global client.mutedUsersStore.
  • [Breaking] In-place mutation of channel.data.member_count / channel.data.own_capabilities no longer syncs to state; reassign channel.data.

We should not mark this change as breaking as these have never made it to v9 (master).

it's breaking in terms of V10 of course and it can be useful if someone pulls stuff in so that they know what's changed, since V10 is anyway going to be squashed at the end (probably) I can't see why it matters

Because as an integrator you are looking at a diff v9 - v10. If we say there is a breaking change that is actually not a breaking change, it is misleading. I would just not put these into the description of the commit that will be created with this PR being merged.

You're actually just looking at a changelog, which will contain all changes that are relevant and breaking :D

And also a migration guide.

But I'll remove them

Comment thread src/channel.ts Outdated
@MartinCupela

Copy link
Copy Markdown
Contributor

In the PR description there is the following:

  • [Breaking] Removed the per-domain channel.state.*Store handles (readStore, typingStore, membersStore, watcherStore, ownCapabilitiesStore); subscribe via useStateStore(channel.state, selector) instead.
  • [Breaking] Removed channel.state.mutedUsersStore and the MutedUsersState type; use the client-global client.mutedUsersStore.
  • [Breaking] In-place mutation of channel.data.member_count / channel.data.own_capabilities no longer syncs to state; reassign channel.data.

We should not mark this change as breaking as these have never made it to v9 (master).

it's breaking in terms of V10 of course and it can be useful if someone pulls stuff in so that they know what's changed, since V10 is anyway going to be squashed at the end (probably) I can't see why it matters

Because as an integrator you are looking at a diff v9 - v10. If we say there is a breaking change that is actually not a breaking change, it is misleading. I would just not put these into the description of the commit that will be created with this PR being merged.

You're actually just looking at a changelog, which will contain all changes that are relevant and breaking :D

And also a migration guide.

But I'll remove them

You mean breaking btw v10 rc.2 and v10 rc.x?

Comment thread src/channel.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants