Skip to content

feat!: establish network connection observer services - #1859

Open
MartinCupela wants to merge 8 commits into
release-v10from
feat/network-connection-observer
Open

feat!: establish network connection observer services#1859
MartinCupela wants to merge 8 commits into
release-v10from
feat/network-connection-observer

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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

Question Answered by
Does this device have a network? client.networkConnectionnew
Is our WebSocket up, and on which connection id? client.wsConnection
Have we finished re-syncing after a reconnect? client.connectionRecovery, which dispatches connection.recovered

Apps 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:

client.config.set({
  client: {
    networkConnection: {
      statusListenerRegistrar: (onStatusChange) => {
        onStatusChange(navigator.onLine); // report the current value immediately
        const handle = () => onStatusChange(navigator.onLine);
        window.addEventListener('online', handle);
        window.addEventListener('offline', handle);
        return () => {
          window.removeEventListener('online', handle);
          window.removeEventListener('offline', handle);
        };
      },
    },
  },
});

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.isOnline is
undefined rather than false: the SDK won't guess, because a fabricated "online" can't be
told apart from a real reading.

So isOnline has three values, and the code deciding whether to render an offline banner has
to handle all three:

if (client.networkConnection.isOnline === false) {
  // definitely offline — show the banner
}

if (!client.networkConnection.isOnline) {
  // WRONG: `undefined` is falsy too, so this also shows the banner when the status is
  // unknown — a React Native app with no registrar would show "you're offline" forever
}

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:

  • Channels were watched against dead connections. "Watching" a channel means asking the
    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 client
    had 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.
  • A backgrounded app came back to a stale thread list. ThreadManager reloaded only if it had
    seen connection.changed { online: false } — an event client.closeConnection() never
    dispatches, and closeConnection() is the path a mobile app takes when backgrounded. It also
    never reset the lastConnectionDropAt timestamp it recorded, so after the first drop of a
    session that check stopped gating anything at all.
  • connection.recovered fired when nothing had recovered. Recovery reloads every open channel
    with Promise.allSettled, so one failure doesn't stop the rest — but a network drop mid-recovery
    could 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.
  • Replaced sockets were abandoned rather than closed. client.openConnection() overwrote the
    current 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 WebSocket

Before Now
connection.changed / connection.recovered carried only online both carry connection: 'network' | 'ws' — check it first, or a socket drop on a working network reads as the device going offline
StableWSConnection.isHealthy .isOnline
client.defaultWSTimeout client.config.set({ client: { wsConnection: { connectTimeoutMs } } })
WebSocketImpl / wsUrlParams / wsConnection client options wsConnection config: webSocketImpl / urlParams / connection
client._getConnectionID() client.wsConnection.connectionID

Unlike the fields and options they replace, these survive a reconnect.

fix: reload the thread list on reconnect without a self-owned drop flag

Before Now
client.threads.state.lastConnectionDropAt client.wsConnection.state.lastOfflineAt

feat!: wait for a live socket in channel.watch() and queryChannels()

Both now wait for a live socket (up to connectTimeoutMs, 15s) instead of returning unwatched
data, 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: false is still
honoured. client._hasConnectionID() is removed.

⚠️ If you have tests that mock a connected client, this is the one to watch. A fixture
faking a connected user without a live socket will now see watch() wait and throw. One line
to fix — mark the socket up — but it broke 552 tests in stream-chat-react first. The error
names it: Cannot wait for a WebSocket connection: none has been opened.

Behaviour change, no API change: connection.recovered is withheld when the network drops
mid-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 the
form commitlint and semantic-release parse, verified against this repo's config.

BREAKING CHANGE: `connection.changed` and `connection.recovered` now carry a `connection:
'network' | 'ws'` field. Narrow on it before reading `online`, or a socket drop on a working
network reads as the device going offline.

BREAKING CHANGE: `StableWSConnection.isHealthy` is renamed to `isOnline`.

BREAKING CHANGE: `client.defaultWSTimeout` is removed, along with the `WebSocketImpl`,
`wsUrlParams` and `wsConnection` client options. They move to `client.wsConnection.config` as
`connectTimeoutMs`, `webSocketImpl`, `urlParams` and `connection`, set with
`client.config.set({ client: { wsConnection: { … } } })`. Unlike the fields and options they
replace, these survive a reconnect.

BREAKING CHANGE: `client._getConnectionID()` and `client._hasConnectionID()` are removed. Read
`client.wsConnection.connectionID`, or `client.wsConnection.isOnline` where the intent was "is
the connection up" — the connection id is never cleared, so it stayed truthy through a drop.

BREAKING CHANGE: `ThreadManagerState.lastConnectionDropAt` is removed. Read
`client.wsConnection.state.lastOfflineAt`, which is written on every status transition,
including the `disconnect()` path the event is silent about.

BREAKING CHANGE: `channel.watch()` and `client.queryChannels()` now wait for a live WebSocket,
up to `connectTimeoutMs` (15s by default), instead of silently returning unwatched data, and
throw if one does not arrive. The channel is then left unwatched, offline support renders it
from the local database, and the next reconnect reloads it. An explicit `watch: false` from the
caller is still honoured. Test fixtures that fake a connected user without a live socket will
see `watch()` throw `Cannot wait for a WebSocket connection: none has been opened.`

BREAKING CHANGE: `connection.recovered` is no longer dispatched when the device network 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. Unaffected when no
network status registrar is installed.

`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 MartinCupela changed the title feat: establish network connection observer services feat!: establish network connection observer services Sep 10, 2026
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.

1 participant