feat: channel state migration - #1834
Conversation
…tate-migration # Conflicts: # src/channel.ts
|
In the PR description there is the following:
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? |
## 🎯 Goal SDK PR for [this one in the LLC](GetStream/stream-chat-js#1834), as part of the entire state rewrite endeavour. More information there. Additionally, it also refactors/fixes all of the failing tests on V10 as now the state layer is finally stabilizing. The only failing tests are left deliberately failing because they aren't yet properly implemented in the new state layer. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
## [10.0.0-rc.7](v10.0.0-rc.6...v10.0.0-rc.7) (2026-08-21) ### ⚠ BREAKING CHANGES * `client.instanceConfigurationService` is replaced by `client.config`, and the exported class `InstanceConfigurationService` is renamed to `InstanceConfigurationRegistry`. * `client.setInstanceConfigurationFunction()` is removed. Register setup functions with `client.config.setSetupFunction(key, fn)`. `client.setMessageComposerSetupFunction()` is unchanged. * the per-class setup types are replaced by generic ones keyed by instance. Removed: `ChannelSetupFunction`, `ChannelSetupState`, `ChannelTearDownFunction`, `MessageComposerSetupFunction`, `MessageComposerSetupState`, `MessageComposerTearDownFunction`, `StreamChatSetupFunction`, `StreamChatSetupState`, `StreamChatTearDownFunction`, `ThreadSetupFunction`, `ThreadSetupState`, `ThreadTearDownFunction`, `SetInstanceConfigurationFunctions`, `SetInstanceConfigurationServiceStates` and `SetupFnOf`. Use `InstanceSetupFunction`, `InstanceSetupState`, `InstanceSetupTearDownFunction` and `InstanceSetupKey`. * `ChannelInstanceConfig` is renamed to `ChannelConfig` and `ThreadInstanceConfig` to `ThreadConfig`. * `Channel.getConfig()` is removed. Read the server's channel-type configuration from `channel.serverConfig`, or the resolved configuration from `channel.config`. * `client.configs` is renamed to `client.channelServerConfigs` and `client.configsStore` to `client.channelServerConfigsStore`. Keys are still cids, so lookups translate directly. Prefer `channel.serverConfig`. * `channel.config.commands` is renamed to `channel.config.availableCommands`. The server owns the list, so it is read-only and absent from the declarative configuration tree. * `applyCommandValidatorOverride` is no longer exported. Command validation is configured through the composer's configuration. * `MessageComposerConfig` gains a required `polls`; `AttachmentManagerConfig` gains required `enabled` and `customCdn`; `LocationComposerConfig` gains a required `minShareDurationMs`. A config object built literally must supply them. * `linkPreviews.enabled` now defaults to `true`, where it defaulted to `false`. It is ANDed with the channel type's `url_enrichment`, so `true` means "no opinion, let the server decide"; the old default double-gated the feature and kept it off even where the server had enabled it. * server flags gate the features they describe, ANDed with the registered value rather than the client value winning: `typingEvents` with `typing_events`, `readEvents` with `read_events`, `attachments` with `uploads`, `polls` with `polls`, `location` with `shared_locations`, and `linkPreviews` with `url_enrichment`. Code that set a flag to `true` and assumed it took effect may now find the feature off. * setters no longer discard a write when the server is masking the field. Previously the setter compared against the effective value, so the write was dropped and the earlier request was honoured once the server relented. Affects `linkPreviewsManager.enabled`, `textComposer.enabled`, `textComposer.maxLengthOnEdit`, `textComposer.maxLengthOnSend` and `attachmentManager.maxNumberOfFilesPerMessage`. ### Features * add instance configuration service ([#1831](#1831)) ([434966c](434966c)) * channel state migration ([#1834](#1834)) ([9876add](9876add))
|
🎉 This PR is included in version 10.0.0-rc.7 🎉 The release is available on: Your semantic-release bot 📦🚀 |
CLA
Description of the changes, What, Why and How?
What
Converges a channel's per-domain reactive stores into one reactive state —
channel.state, aStateStore<ChannelStateData>— subscribed to exactly likethread.state:The per-domain
*Storehandles (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).messagePaginatorand 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, reactivechannel.statelets a consumer subscribe to any slice through a single selector and deletes that per-SDK plumbing.How
ChannelStatenowextends StateStore<ChannelStateData>(not composition — the store'sprotectedmembers mean a wrapper isn't assignable toStateStore<T>, souseStateStore(channel.state, …)wouldn't typecheck otherwise).ChannelStateDatais flat, with the same top-level keys the old per-store shapes used, so existing selectors stay contravariantly assignable and do not need retyping:Convenience getters/setters (
channel.state.members,.read,.typing,.watchers,.member_count,.watcher_count) are kept and now proxy the single store; all writes go throughpartialNext, 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 viasubscribeWithSelectorover thereadslice),src/types.ts(AIState/AIStates).Breaking vs v9
channel.disconnectedchannel.pendingDisposal_disconnect()disposes the paginators, unregisters the subscriptions, and the client drops the channel fromactiveChannelsright 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 }).AIStateincl.AI_STATE_CHECKING_SOURCESAI_STATE_EXTERNAL_SOURCES, plusAI_STATE_IDLE/AI_STATE_STOP; canonicalAIStatesconst now exported(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-v10rather than as migration steps:useStateStore(channel.state.readStore | typingStore | membersStore | watcherStore | ownCapabilitiesStore, sel)useStateStore(channel.state, sel)— drop the.<X>Store, keep the selector verbatimchannel.state.mutedUsersStoreclient.mutedUsersStore(muted users are client-global)MutedUsersStatetypechannel.data.member_count = n/.own_capabilities = [...]synced to statechannel.data = { ...channel.data, member_count: n }(theObject.definePropertyaccessors are gone)Additive
channel.state— the single unifiedStateStore<ChannelStateData>.data(reactive channel data POJO),membership,muteStatus({ muted, createdAt, expiresAt }, fromclient.mutedChannels),initialized/offlineMode/pendingDisposal,aiState,active.channel.activate()/channel.deactivate()/channel.active— refcount-backed "a consumer is currently consuming this channel" flag (aChannelinstance 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 isChannelActivationState, not a UI state.AIStatesconst exported from the package.Behavioral
ai_indicator.update/.clear/.stopin_handleChannelEvent(.stopis newly honoured). It auto-resets toIdleon connection loss — transient/internet drop via the health-gated channel cleaning sweep (clean()), deliberate close (e.g. mobile backgrounding) viaclient.closeConnection()— so a stuck "Generating" can't outlive a lost socket. WS-driven only (no optimistic self-update); ephemeralai_indicator.*events are not replayed on reconnect by design.muteStatuspublishes only on real change (recomputed fromclient.mutedChannels; no churn on the frequenthealth.checkfan-out) viaclient._reflectMutedChannelsToActiveChannels().channel.state.unreadCountwas a plain (non-store) field kept in parallel withread[ownUserId].unread_messages; it is now a derived getter over that read slice, so the numberchannel.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 whilemessagePaginator.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, orquery({ watch: false, state: false })) so the count still accumulates there.unreadCount/read[me]kept consistent on channel-wide resets —channel.truncatedand "all channels read" route through_setOwnUnreadCount, which writes the read row (guarded; only reconciles an existing entry).!c.active(only inhydrateActiveChannels, never onchannel.query)._disconnect(subscriptions down before flippingpendingDisposal, since store writes during teardown would trip agetClient()-reading subscription).Kept deliberately separate
messagePaginator(+unreadStateSnapshot/ live-view state),pinnedMessagesPaginator,messageComposer, andchannel.configState.Review follow-ups (addressed)
Channel._syncStateFromChannelDatawrapper removed — every call site already passed both arguments, so its default duplicatedChannelState.syncStateFromChannelData's own.ChannelUIState→ChannelActivationState, and theactive/activate()/deactivate()docs no longer describe UI ("mounted", "on-screen") — the client stays headless.ChannelState.unreadCountfield → derived getter (single source of truth, see Behavioral above).isDirectChannelslice dropped — classifying 1:1 bymemberCount === 2is an opinionated definition (products that treat a DM as a creation-time property would disagree, and a 4 → 3 → 2 group would flip). Consumers derivememberCount === 2themselves at no extra cost:memberCountpublishes on the same rare events the slice did.channel.disconnected→channel.pendingDisposal, old name removed outright rather than deprecated (see Breaking vs v9).currentReadStoreState→currentStatein_patchReadState; leftoverTODO #29references removed.Testing
Full suite green: 2740 passed / 1 todo;
yarn typesclean;yarn lintclean; dist builds. Coverage added intest/unit/channel_state.test.js,channel.test.js,client.test.js, andmessageDelivery/MessageReceiptsTracker.test.ts(handle-identity asserts dropped; sibling-preservation, subscribe-by-selector, the derivedunreadCount, and the lifecycle / mute / AI /activeslices covered).