Skip to content

feat!: establish network connection observer services - #1859

Merged
MartinCupela merged 27 commits into
release-v10from
feat/network-connection-observer
Sep 18, 2026
Merged

MartinCupela merged 27 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.networkConnection — new
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
Comment thread src/connection/ConnectionRecoveryManager.ts Outdated
Comment thread src/connection/networkConnection/NetworkConnectionObserver.ts Outdated
Comment thread src/connection/networkConnection/NetworkConnectionObserver.ts
Comment thread src/channel.ts Outdated
Comment thread src/client.ts Outdated
Comment thread src/utils/waitForWSConnection.ts Outdated
Comment thread src/connection/wsConnection/StableWSConnection.ts Outdated
Comment thread src/types.ts Outdated
Comment thread src/connection/wsConnection/types.ts Outdated
Comment thread src/connection/networkConnection/types.ts Outdated
Comment thread src/connection/networkConnection/NetworkConnectionObserver.ts
Comment thread src/connection/ConnectionRecoveryManager.ts
Comment thread src/connection/networkConnection/registrars.ts Outdated
MartinCupela and others added 12 commits September 16, 2026 09:59
`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>
MartinCupela and others added 3 commits September 16, 2026 13:27
… 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>
MartinCupela and others added 4 commits September 17, 2026 17:11
…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>
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>
@MartinCupela
MartinCupela merged commit 4c46949 into release-v10 Sep 18, 2026
4 checks passed
@MartinCupela
MartinCupela deleted the feat/network-connection-observer branch September 18, 2026 12:00
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))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 10.0.0-rc.12 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants