feat!: establish network connection observer services - #1859
Open
MartinCupela wants to merge 8 commits into
Open
feat!: establish network connection observer services#1859MartinCupela wants to merge 8 commits into
MartinCupela wants to merge 8 commits into
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
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.