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? |
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).