feat!: establish network connection observer services - #1859
Merged
Merged
Conversation
`client.networkConnection` is a new reactive service reporting the device's network, written only by an integrator-supplied registration function. The SDK cannot detect this itself — every platform reports it differently — so it has to be told. A browser registrar is installed by default; React Native and other hosts supply one, or the status stays `undefined`, meaning unknown rather than offline. The WebSocket becomes a consumer of that signal instead of the thing that detects it: it no longer registers `window` listeners and takes its offline/online edge from the observer. `client.wsConnection` is now a stable object created with the client, owning the socket's reactive status, its configuration and the one network subscription. The live `StableWSConnection` hangs off `.connection`, built by `WSConnection.connect()` rather than assigned from outside. Its store is written on every transition, including `disconnect()` and the error paths where `connection.changed` is silent. Why three separate facts and not one boolean: a socket dies on a working network (server close, expired token, health-check timeout), and a device drops while the socket still looks healthy for up to 35s. Conflating them is what makes offline UI blame the network for a dead socket. Network status is an accelerator, never a precondition — nothing in the SDK requires it, and with no registrar installed everything behaves as it did before. Also removes three dead helpers from `utils.ts` — `isOnline()` and the `add`/`removeConnectionEventListeners()` pair — which had no callers left and were never exported. BREAKING CHANGE: `connection.changed` and `connection.recovered` now carry `connection: 'network' | 'ws'` — check it before reading `online`, or a socket drop on a working network reads as the device going offline. `StableWSConnection.isHealthy` is now `isOnline`. `client.defaultWSTimeout` and the `WebSocketImpl`, `wsUrlParams` and `wsConnection` client options move to `client.wsConnection.config` as `connectTimeoutMs`, `webSocketImpl`, `urlParams` and `connection`; unlike the fields they replace, these survive a reconnect. `client._getConnectionID()` is removed — read `client.wsConnection.connectionID`. See `docs/network-connection.md` and `v9-to-v10-migration-guide-other.md`.
`ThreadManager` reloaded the thread list after a reconnect only if it believed a
disconnect had happened first, and it recorded that belief itself in
`lastConnectionDropAt`, written from `connection.changed { online: false }`. The
gate was wrong in both directions:
- it missed recoveries, because that event is not a reliable disconnect signal.
Going offline is delayed and suppressed entirely on a quick flap, and
`closeConnection()` — the documented mobile background/foreground path — never
dispatches it at all. A backgrounded app came back to a stale thread list.
- it then stopped gating anything, because the flag was written once and never
cleared: the setter kept any existing value, and `reload()` clears
`isThreadOrderStale` but never touched this.
The gate is also unnecessary. `connection.recovered` is dispatched by
`ConnectionRecoveryManager` on every reconnect path, so it already implies a drop
happened — which was not true when this code was written, back when only
`_reconnect()` produced it. The reload now runs off that event alone, keeping the
`wasActivatedAtLeastOnce` guard and the recovery throttle.
The handler also narrows on `connection === 'ws'`. Nothing dispatches a
`'network'` recovery today, but recovery is about to observe both connections and
a network blip is not a reason to requery every thread list.
BREAKING CHANGE: `ThreadManagerState.lastConnectionDropAt` is removed. Read
`client.wsConnection.state.lastOfflineAt` instead, which is written on every
status transition including the `disconnect()` path the event is silent about.
`WSConnection.connect()` built a new socket and overwrote the reference without shutting the old one down. The abandoned `StableWSConnection` was left with both its timers armed — nothing else clears the ping and connection-check timers — and with `isDisconnected` still false, which is the flag `_reconnect()` checks before giving up. So it stayed live and kept reconnecting alongside its replacement: two sockets, two ping loops, and whichever answered last winning the client's status. `openConnection()` returns early when a healthy connection exists or an attempt is in flight, so a working socket was never replaced. The gap is a socket that is down but not disconnected — the state a health-check timeout leaves behind — followed by an `openConnection()`, which a mobile app foregrounding without a matching `closeConnection()` reaches. The previous socket is now disconnected before the new one is installed, skipped when `buildConnection()` returns the same instance, which it does for an injected one. Fire and forget: bumping `wsID`, clearing the timers and setting `isDisconnected` all happen synchronously, and only the socket close is awaited.
…recovery
`ConnectionRecoveryManager` reloads active channels and threads with
`Promise.allSettled`, so one failure never stops the others. The consequence was
that a network drop during a recovery could fail every single reload while
`connection.recovered` was still dispatched — telling consumers that what is on
screen is fresh when none of it had been refreshed. The UI SDKs'
mark-read-on-catch-up keys off that event, so it would mark messages read that
were never fetched.
Two boundaries, read from `client.networkConnection` rather than inferred from
the socket's own event — a network drop reaching the manager as a socket event is
indistinguishable from a socket that died for its own reasons:
- before starting, skip when the device reports no network. The next socket
reconnect starts a fresh recovery.
- before dispatching completion, skip if the network dropped while the reloads
ran. Withholding is safe rather than stranding: a drop guarantees a later
reconnect, and that recovery dispatches the event.
The mid-recovery check compares `lastOfflineAt` rather than reading `isOnline`
afterwards, because a network that drops and returns inside the recovery window
has failed the reloads just the same while ending up online.
Both conditions are `=== false` and a timestamp comparison, so an unknown network
— no registrar installed, which is React Native today, Node and SSR — can neither
suppress a recovery nor withhold its completion. `connection.changed
{ connection: 'ws', online: true }` remains the only trigger.
Both awaited `client.wsPromise` and then downgraded to `watch: false` if the client had no connection ID. That was wrong in both directions. `wsPromise` is only a pending promise while `openConnection()` is in flight and is already resolved during a socket-internal reconnect, so the wait covered the wrong case. And the guard read the connection ID, which is assigned on a successful connect and never cleared — so during a reconnect it did not downgrade at all: it sent `watch: true` against a dead connection, and the channel then recorded `watchStatus = Watching` when nothing was watching. Where it did downgrade, it returned unwatched data that a second, watched query had to follow. Neither outcome was wanted. Both now wait on `client.wsConnection.state`, which is written on every transition, and always send `watch: true` exactly once, always bound to a connection ID that is current. `watchStatus = Watching` is truthful by construction rather than by a guard that could be wrong. New `waitForWSConnection()` rejects immediately, without burning the timeout, when no socket is expected: no user connected, no socket ever opened, or a connection closed deliberately with `closeConnection()`. Opening a channel on a backgrounded app fails at once rather than blocking. The wait defaults to `client.wsConnection.config.connectTimeoutMs` — deliberately the same budget the socket itself gets to connect, rather than a new knob. `_hasConnectionID()` goes with the downgrade it guarded; the `openConnection()` early-return reads `wsConnection.connectionID` directly, and the hydration backstop reads `isOnline`, which is the fact it actually meant. `getClientWithUser` now marks the socket up as well as the user connected. Its fiction was incomplete — in the SDK "connected" means a live socket with a connection id — and it became load-bearing once `watch()` started waiting. BREAKING CHANGE: opening a channel or querying channels while the socket is down now waits, up to `connectTimeoutMs` (15s by default), instead of returning unwatched data immediately, and throws if the socket does not come back. That is not a dead end: the channel stays unwatched, offline support renders it from the local database, and `ConnectionRecoveryManager` reloads it on the next reconnect — its recovery is filtered on whether a channel is active, never on `watchStatus`. An explicit `watch: false` from the caller is still honoured. `client._hasConnectionID()` is removed.
`api-client` sends `client.wsConnection.connectionID` as `connection_id`, and that read
came back `undefined` for the whole life of the connection. Every watched request failed:
QueryChannels failed with error: "Watch or ChatPresence requires an active
websocket connection, please make sure to include your websocket connection_id"
Three things lined up. `connectionID` was assigned in `_connect()` from the resolved
`connectionOpen` promise, which runs a microtask *after* `onmessage` has already called
`_setOnline(true)`. So the status store went online carrying `connectionId: undefined`.
And `_setStatus` ignores a repeat of the same `isOnline`, so nothing could fill it in
afterwards.
`connectionID` is now assigned in `onmessage`, from the same hello event, before the
socket announces itself. "The socket is up" therefore implies "there is an id to watch
on", which is what the rest of the SDK already assumed.
`waitForWSConnection` now requires both `isOnline` and a connection id, because the id is
what its callers actually need. The two move together after this change, so requiring both
means a future reordering shows up as a wait that times out rather than as a 400 from the
server.
Nothing caught this because every fixture set the status by calling
`_setStatus({ isOnline: true, connectionId: '…' })` by hand, supplying the id the real
handshake did not. The new test drives the mock handshake instead and asserts the id is
present in both the socket's field and the store once `isOnline` is true; it fails with
the early assignment removed.
MartinCupela
requested review from
isekovanic,
oliverlaz,
santhoshvai,
szuperaz and
vishalnarkhede
as code owners
September 10, 2026 08:50
isekovanic
requested changes
Sep 15, 2026
isekovanic
reviewed
Sep 15, 2026
isekovanic
requested changes
Sep 15, 2026
`updateConfig({ statusListenerRegistrar })` wrote the value into the resolved config and
stopped there. Installation only ever happened inside `initializeConfig`, so the registrar
was never invoked, no platform listener was attached, and `isOnline` stayed `undefined`
for the life of the client. There was no error anywhere: the outcome is indistinguishable
from a host that has no registrar to offer.
Installation now lives in one private resolver that both `initializeConfig` and
`updateConfig` call, so every path that can change what should be installed goes through
the same fallback to the platform default instead of reimplementing it.
Reported while integrating the React Native SDK, where the declarative route through
`client.config.set` is the one that worked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`setStatusListenerRegistrar` installed a listener and left no trace in configuration, while every configuration derivation reinstalled whatever the declarative tree named, falling back to the platform default. So a `client.config.set` on *any* `client` key — not only a network one — unsubscribed the registrar and put the default in its place. On React Native there is no default, so the listener was gone and the device's status silently froze at whatever had been reported last. `disconnectUser()` followed by `connectUser()` did the same, because that re-arms the `client` key. The setter now records what it was given, and the derivation reinstalls it. Recorded rather than written into the resolved config: `initialize` clears the patch layer by design, so a patch could not have survived the very derivations this is about. Whichever source spoke last wins. A derivation that names a *different* registrar supersedes an earlier imperative call, and an imperative call supersedes what was declared. Clearing with `null` survives a derivation too, rather than being resurrected as the platform default: "explicitly none" is a decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`registerSubscriptions` added the teardown that releases the platform listener but never installed one, so the pairing only worked in one direction. After an `unregisterSubscriptions()` the listener was gone, and registering again did not bring it back: the observer stayed permanently deaf while still looking healthy from the outside. `WSConnection` and `ConnectionRecoveryManager` both resubscribe on re-registration; this one did not. It now reinstalls whatever the current configuration resolves to, skipping the very first call — the client registers subscriptions while it is still being constructed, and reporting a status into a half-built client is not worth the symmetry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both methods waited for a live WebSocket before looking at what the caller had asked for, so an explicit `watch: false` still blocked for the connect budget and then threw. That turned the one option meant for reading without a subscription into the one call that could not be made offline. The wait now runs after the options are merged, and only when the request actually needs a connection ID. `presence` counts as well as `watch`: both are server-side subscriptions keyed by that ID, and the server rejects either without one — which is why gating on `watch` alone would have been wrong in the other direction. Reported against both call sites separately, and both are covered: a plain read now goes out with the socket down, while presence without watch still waits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`browserNetworkStatusListenerRegistrar` is part of the public API but dereferenced `navigator.onLine` and `window.addEventListener` unguarded, so calling it anywhere that is not a browser threw. Only the `getDefault…` wrapper feature-detected, which helps nobody who hands the registrar to `client.config.set` themselves — from a Node process, or a server-side render of code written for the browser. It now feature-detects the same way, and reports nothing when it cannot: no listener, no fabricated status, `isOnline` left `undefined`. That is the honest answer every non-browser host already gets, and failing quietly is what the registrar contract is for. Both halves are required rather than either. Installing listeners without a readable `navigator.onLine` would mean inventing the value this module exists not to invent, so the detection is now one predicate shared with the default picker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`api-client` sends `client.wsConnection.connectionID` as `connection_id` on every request, and the status store kept the id after the socket went down. That was survivable while the socket object was rebuilt on each connect, because the rebuild reset it. This object now outlives every socket it wraps, so nothing cleared it: requests between reopening a connection and the server's hello carried the id of the connection that had just died — after a `disconnectUser()`, the previous user's. The id is dead the moment the socket is. The server keys channel watches by it and rejects a request carrying one it has already closed, so there is no window in which the old value is the right thing to send. `waitForWSConnection` gains something from this too. It requires both a live socket and an id, which until now could not disagree in the direction that mattered, since the id stayed truthy through every drop. BREAKING CHANGE: `client.wsConnection.state.connectionId` is cleared when the socket goes down instead of retaining the last value. Code reading it to mean "connected at some point" should read `lastOnlineAt`; code reading it to mean "connected now" was already supposed to read `isOnline` and keeps working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`queryChannels` forwards the caller's signal to the HTTP call, but the wait for a live socket runs before that call exists. So aborting an in-flight query did nothing until the wait finished on its own: a search the user had already moved on from held its timer for the whole connect budget, then rejected on a timeout that no longer described anything. The wait now accepts the signal, rejects as soon as it fires, and rejects immediately for a signal that was already aborted. It rejects with the caller's own abort reason where there is one, so code that recognises an abort from the HTTP layer recognises this one too without learning a second shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard that withholds the event only watched the device's network. A socket dying while the reloads run is the more common failure of the two — a server close, an expired token, a health check timing out, none of which touch the device's network — and `Promise.allSettled` means it can fail every reload while recovery still reaches the end. Announcing recovery there tells consumers that what is on screen is fresh when none of it was refreshed, and the UI SDKs' mark-read-on-catch-up keys off this event, so it marks messages read that were never fetched. The socket's own `lastOfflineAt` is the better signal, and now the primary one. It is written on every status transition, including `disconnect()` and the two error paths the event is silent about, and unlike the network's it exists on every host: with no registrar installed the network timestamp stays `null` and its status unknown, so neither network condition could ever fire — React Native, Node and server-side rendering had no protection at all. Both are checked, since either failing invalidates the reloads, and both compare timestamps rather than reading the boolean afterwards: a connection that drops and returns inside the recovery window ends up online while having failed everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`recover()` logged "A recovery is already in flight" and then started one anyway. Two overlapping passes reload every active channel twice and race on the in-flight flag: the second clears it while the first is still running, so the flag stops describing anything and a third caller starts a third pass. Pre-existing rather than introduced here: it arrived with the connection recovery work and is unchanged on `release-v10`. Fixed alongside the rest because this PR is already in the file, and because the public `recover()` is now the documented way to force a pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Registrar" describes a record keeper, and this is a function that reports a status: it takes a callback and returns an unsubscribe, which is the same contract as `StateStore.subscribe` and as `useSyncExternalStore`. Reviewers read the old name as bookkeeping and had to look up what it meant, which for a one-function public contract is the whole cost of the name. The rename is mechanical and complete: the type, the configuration key, the setter, the built-in browser implementation, the default picker and the module they live in. BREAKING CHANGE: `NetworkStatusListenerRegistrar` is now `NetworkStatusReporter`, `client.networkConnection.setStatusListenerRegistrar()` is `setStatusReporter()`, the configuration key `client.networkConnection.statusListenerRegistrar` is `statusReporter`, and the exported `browserNetworkStatusListenerRegistrar` is `browserNetworkStatusReporter`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Connectivity was published twice: once as `client.wsConnection.state` and `client.networkConnection.state`, and again as a `connection.changed` event carrying a discriminator to say which of the two it meant. Two descriptions of one fact, and they did not agree. The stores were written on every transition; the event was silent on `closeConnection()` and two error paths, announced a drop five seconds late, and skipped it entirely if the socket returned inside that window. Every consumer had to know which of the two it was reading and why they differed. The stores are now the whole interface. The four consumers inside this package subscribe to the one they mean: the socket reads the network store, while connection recovery and the offline sync manager read the socket's. The delay is gone with it. Sitting on a drop so a brief flap does not strobe a "connection lost" banner is a presentation decision, and `WS_OFFLINE_ANNOUNCE_DELAY_MS` stays exported as the value to debounce by so the UI SDKs do not each pick their own. It also removes a timer that outlived the socket that armed it, reading a discarded socket's status and announcing a drop that a replacement had already recovered from. Two behaviours follow from reading a store rather than an edge. Recovery skips the immediate first notification, which is the current status rather than a change, and it ignores a first connect: nothing was loaded to fall behind. `lastOfflineAt` is written by every drop including the deliberate close, so every real reconnect still qualifies. The client also builds `networkConnection` before `wsConnection` now. That dependency used to run through the event bus, which did not care about construction order. BREAKING CHANGE: the `connection.changed` event is removed. Read `client.wsConnection.state` for this client's socket and `client.networkConnection.state` for the device's network; both are `StateStore`s, so `subscribe` and `subscribeWithSelector` replace `client.on`. A UI that showed a "connection lost" banner should debounce its own rendering by `WS_OFFLINE_ANNOUNCE_DELAY_MS`, which the event used to do. `connection.recovered` is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device's network status had no default outside the browser, so a React Native integration that forgot to install `@react-native-community/netinfo` left `isOnline` `undefined` for the life of the client and every consumer branching on it dead. Nothing failed and nothing was logged, which is harder to notice than a signal that is merely coarse. Hosts that cannot answer the question now get a stand-in that mirrors this client's WebSocket. It is not a measurement of the network — the two facts disagree routinely, which is why they have separate stores — and it can only ever repeat what the socket already said, so it cannot report that the network came back before the socket noticed. Install a real reporter wherever one exists. A browser still gets the browser reporter, automatically, and a reporter supplied by the integrator still wins. It reports nothing until the socket has been up once. `isOnline` on the socket store is `false` from construction, and forwarding that would claim the device is offline before anything had been attempted, which is the fabricated reading this module exists to avoid. A client that has never connected therefore still answers `undefined`. The two are wired in a circle — the socket reads the network store, the stand-in reads the socket's — and it terminates because the derived value can never lead: applying a status the socket already holds changes nothing. Covered by a test, along with the rest of the no-reporter path. BREAKING CHANGE: `getDefaultNetworkStatusReporter()` now takes the client's `WSConnection` and always returns a reporter, where it took no arguments and returned `undefined` off-browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the client
The socket wrote its status by going back out through `this.client.wsConnection` to reach
the object that owns it, and read its own configuration the same way. Two hops through the
client to get to its parent.
It now holds the parent and reaches the client through it, which is a reference it needs
anyway for the token manager, insights and the authentication message. The status write and
the configuration read are both direct.
The late-injection seam is unchanged in shape: a socket supplied through `config.connection`
is built before the client that will own it exists, so it still adopts its owner afterwards,
through `setWSConnection` rather than `setClient`. The optional chaining that covers the
window between construction and adoption is still there, and the tests that build a
parentless socket still pass.
BREAKING CHANGE: `new StableWSConnection({ client })` is now
`new StableWSConnection({ wsConnection })`, and `setClient()` is `setWSConnection()`. Both
are internal seams rather than documented API; a pre-built socket handed to
`client.config.set({ client: { wsConnection: { connection } } })` is adopted by the client
exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
How long a drop must last before a UI tells anyone about it was a constant, which meant the
only way to change it was to fork the hook that reads it. It is now
`offlineNotificationDisplayDelayMs` on the WebSocket's configuration, so one setting reaches
the React banner, React Native and any custom one, set the same way as the rest and
surviving reconnects. Floored at zero, which is a legitimate setting: report a drop at once.
Nothing in this package waits on it, which is unusual for a configuration field and worth
naming. It is here rather than in each UI SDK so they cannot drift apart, and it reads as a
property of the connection — how long a drop must persist before it counts as one worth
reporting — rather than of the banner that happens to render it.
`WS_CONNECTION_CONFIG_BOUNDS` and the declared shape both required the new field, and the
`Record<keyof WSConnectionConfig, ConfigNode>` in the shape caught its absence at compile
time, which is what that type is there for.
BREAKING CHANGE: `WS_OFFLINE_ANNOUNCE_DELAY_MS` is removed. Read
`client.wsConnection.config.offlineNotificationDisplayDelayMs` instead, which is settable
through `client.config.set({ client: { wsConnection: { … } } })`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ealthy Four changes that could not be separated cleanly, because they overlap in nearly every file they touch. **The connection id gets an owner.** `client.connectionIdManager` holds it, and `ApiClient` holds any request that watches a channel or subscribes to presence until one exists. That replaces the wait this branch had at two call sites, and it covers requests those never did: stop-watching and long polling carry no watch flag and are recognised by their declared `connection_id` parameter instead. A caller's abort signal reaches the wait. Ported from #1870 so the two branches implement it the same way, with one difference — invalidation hangs off the status funnel rather than one drop path, so the deliberate close and the two error paths are covered as well. **`isHealthy` comes back.** The socket's status is a different fact from the device's network, and calling both `isOnline` forced every reader of the pair to alias one of them. `isHealthy` was the name before this branch renamed it; it keeps that name and moves to a getter on `client.wsConnection`, which is not replaced per connect. The store's timestamps follow it to `lastHealthyAt` and `lastUnhealthyAt`, so each store now reads in one vocabulary and no field name is shared between them. **Recovery cannot re-enter.** A reload that moves the socket's status called straight back into the pass that triggered it, recursing until the stack gave out. A reconnect arriving mid-pass is now deferred to the end of that pass and run once, rather than dropped, which would strand it, or stacked, which would reload every active channel twice. **`connection.recovered` loses its discriminator.** One possible value forever, which every consumer had to narrow on, and an omitted field silently skipped the thread list reload. Also: the socket's parent is required rather than optional, since construction-time injection is the one path that needed it and #1870 removes that capability; `channel.watch()` takes an abort signal like `queryChannels`; and the repository documentation is updated throughout. BREAKING CHANGE: `client.wsConnection.state` carries `isHealthy`, `lastHealthyAt` and `lastUnhealthyAt` rather than `isOnline`, `lastOnlineAt` and `lastOfflineAt`, and no longer carries `connectionId` — read `client.connectionIdManager.connectionId`. `connection.recovered` carries no payload. `new StableWSConnection()` requires its `wsConnection` parent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ealthy Four changes that could not be separated cleanly, because they overlap in nearly every file they touch. **The connection id gets an owner.** `client.connectionIdManager` holds it, and `ApiClient` holds any request that watches a channel or subscribes to presence until one exists. That replaces the wait this branch had at two call sites, and it covers requests those never did: stop-watching and long polling carry no watch flag and are recognised by their declared `connection_id` parameter instead. A caller's abort signal reaches the wait. Ported from PR 1870, so the two branches implement it the same way, with one difference — invalidation hangs off the status funnel rather than one drop path, so the deliberate close and the two error paths are covered as well. **`isHealthy` comes back.** The socket's status is a different fact from the device's network, and calling both `isOnline` forced every reader of the pair to alias one of them. `isHealthy` was the name before this branch renamed it; it keeps that name and moves to a getter on `client.wsConnection`, which is not replaced per connect. The store's timestamps follow it to `lastHealthyAt` and `lastUnhealthyAt`, so each store now reads in one vocabulary and no field name is shared between them. **Recovery cannot re-enter.** A reload that moves the socket's status called straight back into the pass that triggered it, recursing until the stack gave out. A reconnect arriving mid-pass is now deferred to the end of that pass and run once, rather than dropped, which would strand it, or stacked, which would reload every active channel twice. **`connection.recovered` loses its discriminator.** One possible value forever, which every consumer had to narrow on, and an omitted field silently skipped the thread list reload. Also: the socket's parent is required rather than optional, since construction-time injection is the one path that needed it and PR 1870 removes that capability; `channel.watch()` takes an abort signal like `queryChannels`; and the repository documentation is updated throughout. BREAKING CHANGE: `client.wsConnection.state` carries `isHealthy`, `lastHealthyAt` and `lastUnhealthyAt` rather than `isOnline`, `lastOnlineAt` and `lastOfflineAt`, and no longer carries `connectionId` — read `client.connectionIdManager.connectionId`. `connection.recovered` carries no payload. `new StableWSConnection()` requires its `wsConnection` parent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…' into feat/network-connection-observer
Reconciles the network-connection-observer work with the OpenAPI clean-up in PR 1870, which moved the socket to src/connection/ and introduced its own ConnectionIdManager. Resolutions of note: - One ConnectionIdManager, at src/connection/ConnectionIdManager.ts; the duplicate src/connection_id_manager.ts is gone, as is the barrel export of it that git merged into src/index.ts without conflict. - StateStore comes from @stream-io/state-store everywhere; src/store.ts is gone. - disconnect() invalidates the connection id rather than resetting it, so a request waiting across closeConnection()/openConnection() survives the cycle instead of being failed. - _getConnectionID()/_hasConnectionID() restored as reads through the manager. - insights.ts removed, along with warmUp, _sayHi() and the /hi warm-up probe. - webSocketImpl, urlParams and the connect timeout stay in wsConnection.config rather than returning to StreamChatOptions; client.defaultWSTimeout stays gone. - The two ConnectionIdManager test files are one, at test/unit/connection/ConnectionIdManager.test.ts. tsc --noEmit is clean and the unit suite passes (127 files, 3859 tests). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isekovanic
approved these changes
Sep 18, 2026
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 18, 2026
## [10.0.0-rc.12](v10.0.0-rc.11...v10.0.0-rc.12) (2026-09-18) ### ⚠ BREAKING CHANGES * `connection.changed` is removed. Connectivity is published as two state stores and nothing else: `client.networkConnection.state` for the device and `client.wsConnection.state` for this client's socket. The event was silent on `closeConnection()` and two error paths, and held a drop for five seconds; the stores are written on every transition and publish immediately. * `client.wsConnection` is now a `WSConnection` wrapper rather than the `StableWSConnection` itself, and is never null. The live socket is `client.wsConnection.connection`, replaced on every connect; read `isHealthy`, `isConnecting`, `connect()` and `disconnect()` from the wrapper. `StableWSConnection` is constructed with `{ wsConnection }` instead of `{ client }`. * `client.defaultWSTimeout` is removed, along with the `WebSocketImpl` and `wsUrlParams` client options. They move to the socket's configuration as `connectTimeoutMs`, `webSocketImpl` and `urlParams`, set with `client.config.set({ client: { wsConnection: { … } } })`. Unlike the fields and options they replace, these survive a reconnect. * `ThreadManagerState.lastConnectionDropAt` is removed. Read `client.wsConnection.state.lastUnhealthyAt`, which is written on every status transition, including the `disconnect()` path the old event was silent about. * requests that watch a channel or subscribe to presence now wait for a WebSocket connection id instead of silently returning unwatched data. The gate is in `ApiClient._doRequest`, so it covers every endpoint carrying `watch` or `presence`, plus `stopWatchingChannel` and `longPoll`. With no socket open and none being established the request rejects with "No connection id is available"; an explicit `watch: false` is still honoured, and a caller's `AbortSignal` abandons the wait. Test fixtures that fake a connected user without a live socket will now throw. * a UI that renders a "connection lost" banner must hold the drop itself. The socket's store publishes drops the moment they happen, where the old event delayed them by five seconds. `client.wsConnection.config.offlineNotificationDisplayDelayMs` (5s) is the shared value to wait for; nothing in this package acts on it. * `connection.recovered` is no longer dispatched when the socket drops while a recovery is running, because every reload in it can have failed. Work keyed off that event will correctly stop running for recoveries that recovered nothing. * openapi related clean up (#1870) ### Bug Fixes * add missing app config fields to AppSettingsAPIResponse ([#1854](#1854)) ([18bc3cf](18bc3cf)) * do not reset channel unread count on thread read ([#1835](#1835)) ([79fbf54](79fbf54)) * hanging wsPromise after closeConnection ([#1868](#1868)) ([b4e7a89](b4e7a89)), closes [#1122](#1122) [#1863](#1863) * isolate event listener errors from the dispatch loop ([#1850](#1850)) ([dc56e57](dc56e57)) * reconnect past connection timeout ([#1874](#1874)) ([2004a83](2004a83)), closes [#1760](#1760) * send the read request regardless of read receipt privacy settings ([#1853](#1853)) ([59d8f31](59d8f31)) ### Features * **client:** support custom_set and custom_unset in batch channel update ([#1856](#1856)) ([d05c2f5](d05c2f5)) * establish network connection observer services ([#1859](#1859)) ([4c46949](4c46949)) * message pruning ([696f56f](696f56f)) * message pruning ([#1875](#1875)) ([d4d2c6c](d4d2c6c)) * **MessageComposer:** add composition middleware for pending attachment uploads ([#1845](#1845)) ([68e5d69](68e5d69)) * openapi related clean up ([#1870](#1870)) ([b3fa906](b3fa906))
|
🎉 This PR is included in version 10.0.0-rc.12 🎉 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?
The SDK told users "you're offline" when their internet was fine. The offline banner was
driven by WebSocket health, and a socket dies for reasons unrelated to the network — the
server closes it, a token expires, a keep-alive times out. It also missed the opposite case:
when a phone really loses signal, the socket takes up to 35s to notice.
Three facts, kept apart
client.networkConnection— newclient.wsConnectionclient.connectionRecovery, which dispatchesconnection.recoveredApps can now say "you're offline" for the first network connection loss and "reconnecting…" for the WS connection loss.
Telling the SDK about the network
One function that subscribes to whatever the platform offers:
Browsers get that registrar for free — the SDK installs it itself. On React Native, in Node
or during SSR you supply one, and until you do
client.networkConnection.isOnlineisundefinedrather thanfalse: the SDK won't guess, because a fabricated "online" can't betold apart from a real reading.
So
isOnlinehas three values, and the code deciding whether to render an offline banner hasto handle all three:
You don't have to supply a registrar at all. The socket still connects and reconnects, messages
still send, queries still run: the SDK never checks network status before doing any of that. It
uses it for one thing only — noticing a dropped connection sooner than the socket's own
35-second keep-alive check would.
Bugs fixed along the way
Independent of the feature, found while auditing it, each its own commit:
server to push that channel's realtime events to this client, and the server ties the
subscription to a WebSocket by its connection id.
channel.watch()checked that the clienthad such an id before asking. But the id is set once when the socket connects and is never
cleared, so during a reconnect the check still saw the previous id and went ahead — subscribing
over a socket that was already gone. The channel then believed it was watching while the server
sent it nothing.
ThreadManagerreloaded only if it hadseen
connection.changed { online: false }— an eventclient.closeConnection()neverdispatches, and
closeConnection()is the path a mobile app takes when backgrounded. It alsonever reset the
lastConnectionDropAttimestamp it recorded, so after the first drop of asession that check stopped gating anything at all.
connection.recoveredfired when nothing had recovered. Recovery reloads every open channelwith
Promise.allSettled, so one failure doesn't stop the rest — but a network drop mid-recoverycould fail all of them, and the event was dispatched regardless. The UI SDKs mark messages read
when they see it, so they marked messages read that had never been fetched.
client.openConnection()overwrote thecurrent socket without disconnecting it, so the old one kept its keep-alive timers and its own
reconnect logic running alongside the new one.
Breaking changes
Each item appears in a
BREAKING CHANGE:footer on the commit named.feat!: derive network status from a platform listener, not the WebSocketconnection.changed/connection.recoveredcarried onlyonlineconnection: 'network' | 'ws'— check it first, or a socket drop on a working network reads as the device going offlineStableWSConnection.isHealthy.isOnlineclient.defaultWSTimeoutclient.config.set({ client: { wsConnection: { connectTimeoutMs } } })WebSocketImpl/wsUrlParams/wsConnectionclient optionswsConnectionconfig:webSocketImpl/urlParams/connectionclient._getConnectionID()client.wsConnection.connectionIDUnlike the fields and options they replace, these survive a reconnect.
fix: reload the thread list on reconnect without a self-owned drop flagclient.threads.state.lastConnectionDropAtclient.wsConnection.state.lastOfflineAtfeat!: wait for a live socket in channel.watch() and queryChannels()Both now wait for a live socket (up to
connectTimeoutMs, 15s) instead of returning unwatcheddata, and throw if it doesn't come back. The channel then stays unwatched, offline support
renders it locally, and the next reconnect reloads it. An explicit
watch: falseis stillhonoured.
client._hasConnectionID()is removed.Behaviour change, no API change:
connection.recoveredis withheld when the network dropsmid-recovery, so you'll stop seeing it for recoveries that recovered nothing. Unaffected when
no registrar is installed.
Ready to paste into the squash commit message — these are
BREAKING CHANGE:footers in theform commitlint and semantic-release parse, verified against this repo's config.