diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json
index 7c3161e09..95f0aa6df 100644
--- a/examples/tutorial/package.json
+++ b/examples/tutorial/package.json
@@ -16,7 +16,7 @@
"emoji-mart": "^5.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
- "stream-chat": "10.0.0-rc.11",
+ "stream-chat": "^10.0.0-rc.12",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
diff --git a/examples/tutorial/src/2-core-component-setup/layout.css b/examples/tutorial/src/2-core-component-setup/layout.css
index c3cf99687..5fa14209f 100644
--- a/examples/tutorial/src/2-core-component-setup/layout.css
+++ b/examples/tutorial/src/2-core-component-setup/layout.css
@@ -1,21 +1,21 @@
html,
body,
#root {
- height: 100%;
+ height: 100%;
}
body {
- margin: 0;
+ margin: 0;
}
#root {
- display: flex;
+ display: flex;
}
.str-chat__channel-list {
- width: 30%;
+ width: 30%;
}
.str-chat__channel {
- width: 100%;
+ width: 100%;
}
.str-chat__thread {
- width: 45%;
-}
\ No newline at end of file
+ width: 45%;
+}
diff --git a/examples/tutorial/src/3-channel-list/layout.css b/examples/tutorial/src/3-channel-list/layout.css
index 2fd790e68..8e25c006e 100644
--- a/examples/tutorial/src/3-channel-list/layout.css
+++ b/examples/tutorial/src/3-channel-list/layout.css
@@ -19,7 +19,7 @@
/* Panel backgrounds */
--str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */
- --str-chat__background-core-app: #c7dafc; /* message list background */
+ --str-chat__background-core-app: #c7dafc; /* message list background */
/* Focus ring */
--str-chat__border-utility-focused: #1e40af;
diff --git a/examples/tutorial/src/4-custom-ui-components/layout.css b/examples/tutorial/src/4-custom-ui-components/layout.css
index 2fd790e68..8e25c006e 100644
--- a/examples/tutorial/src/4-custom-ui-components/layout.css
+++ b/examples/tutorial/src/4-custom-ui-components/layout.css
@@ -19,7 +19,7 @@
/* Panel backgrounds */
--str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */
- --str-chat__background-core-app: #c7dafc; /* message list background */
+ --str-chat__background-core-app: #c7dafc; /* message list background */
/* Focus ring */
--str-chat__border-utility-focused: #1e40af;
diff --git a/examples/tutorial/src/5-custom-attachment-type/layout.css b/examples/tutorial/src/5-custom-attachment-type/layout.css
index 2fd790e68..8e25c006e 100644
--- a/examples/tutorial/src/5-custom-attachment-type/layout.css
+++ b/examples/tutorial/src/5-custom-attachment-type/layout.css
@@ -19,7 +19,7 @@
/* Panel backgrounds */
--str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */
- --str-chat__background-core-app: #c7dafc; /* message list background */
+ --str-chat__background-core-app: #c7dafc; /* message list background */
/* Focus ring */
--str-chat__border-utility-focused: #1e40af;
diff --git a/examples/tutorial/src/6-emoji-picker/layout.css b/examples/tutorial/src/6-emoji-picker/layout.css
index 2fd790e68..8e25c006e 100644
--- a/examples/tutorial/src/6-emoji-picker/layout.css
+++ b/examples/tutorial/src/6-emoji-picker/layout.css
@@ -19,7 +19,7 @@
/* Panel backgrounds */
--str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */
- --str-chat__background-core-app: #c7dafc; /* message list background */
+ --str-chat__background-core-app: #c7dafc; /* message list background */
/* Focus ring */
--str-chat__border-utility-focused: #1e40af;
diff --git a/examples/tutorial/src/7-livestream/layout.css b/examples/tutorial/src/7-livestream/layout.css
index 2fd790e68..8e25c006e 100644
--- a/examples/tutorial/src/7-livestream/layout.css
+++ b/examples/tutorial/src/7-livestream/layout.css
@@ -19,7 +19,7 @@
/* Panel backgrounds */
--str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */
- --str-chat__background-core-app: #c7dafc; /* message list background */
+ --str-chat__background-core-app: #c7dafc; /* message list background */
/* Focus ring */
--str-chat__border-utility-focused: #1e40af;
diff --git a/examples/tutorial/src/tutorial-main.css b/examples/tutorial/src/tutorial-main.css
index c6076e0fc..e21b8d5c6 100644
--- a/examples/tutorial/src/tutorial-main.css
+++ b/examples/tutorial/src/tutorial-main.css
@@ -51,7 +51,9 @@
background: rgba(255, 255, 255, 0.72);
color: #0f172a;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
- transition: transform 160ms ease, box-shadow 160ms ease;
+ transition:
+ transform 160ms ease,
+ box-shadow 160ms ease;
}
.tutorial-browser__step-button--active {
diff --git a/examples/vite/docs-playwright/screenshot-misc.ts b/examples/vite/docs-playwright/screenshot-misc.ts
index 223651439..d64eb75f0 100644
--- a/examples/vite/docs-playwright/screenshot-misc.ts
+++ b/examples/vite/docs-playwright/screenshot-misc.ts
@@ -185,13 +185,12 @@ async function captureConnectionStatus(browser: any) {
// Inject a connection status notification by dispatching an event
await viewPage.evaluate(`(async () => {
var client = window.client;
- // Simulate connection failure notification
- client.dispatchEvent({
- type: 'connection.changed',
- online: false,
- });
+ // Simulate connection failure notification. The status store is the whole interface; the event
+ // this used to dispatch is gone.
+ client.wsConnection._setStatus({ isOnline: false });
})()`);
- await viewPage.waitForTimeout(1500);
+ // The banner holds a drop for five seconds before showing it, so a brief flap does not strobe it.
+ await viewPage.waitForTimeout(6500);
console.log('📸 ConnectionStatus.png');
// Screenshot the bottom area of the message list + notification + input
diff --git a/examples/vite/index.html b/examples/vite/index.html
index 4fbb263d7..dd8e27f14 100644
--- a/examples/vite/index.html
+++ b/examples/vite/index.html
@@ -7,8 +7,15 @@
Stream Chat React
-
-
+
+
diff --git a/examples/vite/package.json b/examples/vite/package.json
index 7b2d34d36..5b6a93529 100644
--- a/examples/vite/package.json
+++ b/examples/vite/package.json
@@ -18,7 +18,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
- "stream-chat": "10.0.0-rc.11",
+ "stream-chat": "^10.0.0-rc.12",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx
index 36eb0d969..94b4ba5ba 100644
--- a/examples/vite/src/App.tsx
+++ b/examples/vite/src/App.tsx
@@ -77,6 +77,7 @@ import {
resolveSingleChannel,
SingleChannelModal,
} from './SingleChannel/SingleChannelApp.tsx';
+import { ConnectionDevPanel } from './ConnectionDevPanel/ConnectionDevPanel.tsx';
import { SystemNotification } from './SystemNotification/SystemNotification.tsx';
import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx';
import {
@@ -291,6 +292,9 @@ const App = () => {
channelCid: state.layout.channelCid,
}));
const { mode: themeMode } = useAppSettingsSelector((state) => state.theme);
+ const { connectionPanel: connectionPanelVisible } = useAppSettingsSelector(
+ (state) => state.devTools,
+ );
const initialChannelId = useMemo(() => getInitialChannelIdFromUrl(), []);
const initialChatView = useMemo(() => getInitialChatViewFromUrl(), []);
const initialThreadId = useMemo(() => getInitialThreadIdFromUrl(), []);
@@ -638,6 +642,7 @@ const App = () => {
ref={appLayoutRef}
style={initialAppLayoutStyle}
>
+ {connectionPanelVisible && }
diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx
index 8a739ee29..234235e12 100644
--- a/examples/vite/src/AppSettings/AppSettings.tsx
+++ b/examples/vite/src/AppSettings/AppSettings.tsx
@@ -26,6 +26,7 @@ import { ReactionsTab } from './tabs/Reactions';
import { SidebarTab } from './tabs/Sidebar';
import { appSettingsStore, useAppSettingsState } from './state';
import {
+ IconConnection,
IconGear,
IconMoon,
IconSidebar,
@@ -159,6 +160,30 @@ const SidebarThemeToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => {
);
};
+const SidebarConnectionPanelToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => {
+ const { devTools } = useAppSettingsState();
+ const { connectionPanel } = devTools;
+
+ return (
+
+ appSettingsStore.partialNext({
+ devTools: { ...devTools, connectionPanel: !connectionPanel },
+ })
+ }
+ role='switch'
+ text={connectionPanel ? 'Hide connection panel' : 'Connection panel'}
+ />
+ );
+};
+
const SidebarRtlToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => {
const {
theme,
@@ -203,6 +228,7 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => {
return (
+
diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts
index 94571be88..2e7db8f35 100644
--- a/examples/vite/src/AppSettings/state.ts
+++ b/examples/vite/src/AppSettings/state.ts
@@ -13,6 +13,12 @@ export type ChatViewSettingsState = {
iconOnly: boolean;
};
+/** Dev-only affordances that would not ship in an application. */
+export type DevToolsSettingsState = {
+ /** Shows the panel that drives the network and WebSocket facts independently. */
+ connectionPanel: boolean;
+};
+
export type ThemeSettingsState = {
direction: 'ltr' | 'rtl';
mode: 'dark' | 'light';
@@ -94,6 +100,7 @@ export type LayoutSettingsState = {
export type AppSettingsState = {
channelDetail: ChannelDetailSettingsState;
chatView: ChatViewSettingsState;
+ devTools: DevToolsSettingsState;
language: LanguageSettingsState;
layout: LayoutSettingsState;
messageActions: MessageActionsSettingsState;
@@ -122,6 +129,9 @@ const isRecord = (value: unknown): value is Record
=>
typeof value === 'object' && value !== null;
const defaultAppSettingsState: AppSettingsState = {
+ devTools: {
+ connectionPanel: false,
+ },
channelDetail: {
modal: {
channelMembersView: {
diff --git a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.scss b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.scss
new file mode 100644
index 000000000..2d4ad3544
--- /dev/null
+++ b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.scss
@@ -0,0 +1,52 @@
+.connection-dev-panel {
+ align-items: center;
+ background: var(--str-chat__secondary-surface-color, #f7f7f8);
+ border-bottom: 1px solid var(--str-chat__surface-color, #e3e5e8);
+ display: flex;
+ flex-wrap: wrap;
+ font-family: monospace;
+ font-size: 11px;
+ gap: 10px;
+ padding: 6px 12px;
+
+ &__label {
+ opacity: 0.6;
+ text-transform: uppercase;
+ }
+
+ &__toggle {
+ align-items: center;
+ background: #fff;
+ border: 1px solid var(--str-chat__surface-color, #d0d3d8);
+ border-radius: 3px;
+ cursor: pointer;
+ display: flex;
+ font-family: inherit;
+ font-size: inherit;
+ gap: 8px;
+ padding: 3px 8px;
+
+ // `false` is the only definite negative. `undefined` means unknown and gets its own colour, so
+ // the state that must not read as offline does not look like it.
+ &[data-state='false'] {
+ background: #ffd7d7;
+ }
+
+ &[data-state='true'] {
+ background: #d7f5dd;
+ }
+
+ &[data-state='undefined'] {
+ background: #ffe9c7;
+ }
+ }
+
+ &__action {
+ opacity: 0.55;
+ }
+
+ &__hint {
+ margin-left: auto;
+ opacity: 0.5;
+ }
+}
diff --git a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx
new file mode 100644
index 000000000..06a69e4d7
--- /dev/null
+++ b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx
@@ -0,0 +1,113 @@
+import { useRef } from 'react';
+import {
+ useChatContext,
+ useNetworkConnectionState,
+ useWSConnectionState,
+} from 'stream-chat-react';
+
+import './ConnectionDevPanel.scss';
+
+/**
+ * Drives the two connection facts independently, so the pair that disagrees can actually be seen.
+ *
+ * DevTools' "Offline" checkbox is no use for this: it takes down `navigator.onLine` **and** the
+ * socket, which is the one combination that always worked. What needs exercising is a dead socket on
+ * a live network — a server close, an expired token, a health-check timeout — because that is the case
+ * the banner used to describe as "Waiting for network…".
+ *
+ * **Both toggles simulate rather than sever.** The socket one writes `client.wsConnection.state`,
+ * which is what the real socket does and what every consumer reads. Closing the real socket is no
+ * good for a toggle: `StableWSConnection` reconnects on its own within a second or two, so it would
+ * flip back by itself.
+ *
+ * Taking the socket down therefore takes five seconds to reach the banner, because `` holds a
+ * drop for `offlineNotificationDisplayDelayMs` before showing it. The label below flips at once.
+ *
+ * For the genuine path, close the socket from the console instead:
+ *
+ * ```js
+ * client.wsConnection.connection.ws.close()
+ * ```
+ *
+ * Dev-only. Nothing here belongs in an application: `setStatus` is for a platform listener rather
+ * than a button.
+ */
+export const ConnectionDevPanel = () => {
+ const { client } = useChatContext();
+ const { isOnline: networkOnline } = useNetworkConnectionState() ?? {};
+ const { isHealthy: socketHealthy } = useWSConnectionState() ?? {};
+ // Not in the socket's store: the id belongs to `client.connectionIdManager`. Read during render
+ // rather than subscribed to, which is enough here - both stores above re-render this panel.
+ const connectionId = client?.connectionIdManager.connectionId;
+ // Parked while the socket is simulated down, so bringing it back hands the *same* id over. See
+ // the toggle below for why inventing one is not an option.
+ const parkedConnectionId = useRef(undefined);
+
+ if (!client) return null;
+
+ return (
+
+ );
+};
diff --git a/examples/vite/src/CustomMessageUi/system-message-variants.css b/examples/vite/src/CustomMessageUi/system-message-variants.css
index 0da863040..7ce87d1d9 100644
--- a/examples/vite/src/CustomMessageUi/system-message-variants.css
+++ b/examples/vite/src/CustomMessageUi/system-message-variants.css
@@ -9,13 +9,5 @@
flex-direction: column;
align-items: center;
border-radius: var(--str-chat__border-radius-md);
- background: linear-gradient(
- to right,
- orange,
- yellow,
- green,
- cyan,
- blue,
- violet
- );
+ background: linear-gradient(to right, orange, yellow, green, cyan, blue, violet);
}
diff --git a/examples/vite/src/CustomMessageUi/variants.css b/examples/vite/src/CustomMessageUi/variants.css
index f2da4e9d2..eadb5708a 100644
--- a/examples/vite/src/CustomMessageUi/variants.css
+++ b/examples/vite/src/CustomMessageUi/variants.css
@@ -24,11 +24,11 @@
}
.custom-message-ui--mine .custom-message-ui__name::before {
- content: "<";
+ content: '<';
}
.custom-message-ui--other .custom-message-ui__name::after {
- content: ">";
+ content: '>';
}
.custom-message-ui--mine {
@@ -84,7 +84,7 @@
.custom-message-ui__metadata > *:nth-child(even)::after,
.custom-message-ui__metadata > *:nth-child(even)::before {
- content: "•";
+ content: '•';
padding-inline: var(--cmui-gap-size-sm);
}
@@ -98,21 +98,21 @@
/* ─── Variant 5: Grouped metadata (hidden by default) ────────────── */
-[data-variant="5"] .custom-message-ui__metadata,
-[data-variant="6"] .custom-message-ui__metadata,
-[data-variant="7"] .custom-message-ui__metadata,
-[data-variant="8"] .custom-message-ui__metadata {
+[data-variant='5'] .custom-message-ui__metadata,
+[data-variant='6'] .custom-message-ui__metadata,
+[data-variant='7'] .custom-message-ui__metadata,
+[data-variant='8'] .custom-message-ui__metadata {
display: none;
}
-[data-variant="5"] .str-chat__li--bottom .custom-message-ui__metadata,
-[data-variant="5"] .str-chat__li--single .custom-message-ui__metadata,
-[data-variant="6"] .str-chat__li--bottom .custom-message-ui__metadata,
-[data-variant="6"] .str-chat__li--single .custom-message-ui__metadata,
-[data-variant="7"] .str-chat__li--bottom .custom-message-ui__metadata,
-[data-variant="7"] .str-chat__li--single .custom-message-ui__metadata,
-[data-variant="8"] .str-chat__li--bottom .custom-message-ui__metadata,
-[data-variant="8"] .str-chat__li--single .custom-message-ui__metadata {
+[data-variant='5'] .str-chat__li--bottom .custom-message-ui__metadata,
+[data-variant='5'] .str-chat__li--single .custom-message-ui__metadata,
+[data-variant='6'] .str-chat__li--bottom .custom-message-ui__metadata,
+[data-variant='6'] .str-chat__li--single .custom-message-ui__metadata,
+[data-variant='7'] .str-chat__li--bottom .custom-message-ui__metadata,
+[data-variant='7'] .str-chat__li--single .custom-message-ui__metadata,
+[data-variant='8'] .str-chat__li--bottom .custom-message-ui__metadata,
+[data-variant='8'] .str-chat__li--single .custom-message-ui__metadata {
display: flex;
}
@@ -197,7 +197,7 @@
padding: 5px;
}
-[data-variant="8"] {
+[data-variant='8'] {
--str-chat__own-message-reaction-background-color: var(--cmui-bg-color, #efefef);
--str-chat__message-reaction-background-color: white;
--str-chat__message-reaction-border-radius: 0.2rem;
diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts
index 813246055..f761e020c 100644
--- a/examples/vite/src/i18n/de.ts
+++ b/examples/vite/src/i18n/de.ts
@@ -258,6 +258,7 @@ export const deTranslations = {
'channelListItem.video.ariaLabel': 'Video',
'channelListItem.voiceMessage.ariaLabel': 'Sprachnachricht',
'channelListItem.voted.text': '📊 {{votedBy}} hat abgestimmt: {{pollOptionText}}',
+ 'chat.reportLostConnection.reconnecting.text': 'Verbindung wird wiederhergestellt…',
'chat.reportLostConnection.waitingNetwork.text': 'Warte auf Netzwerk…',
'command.ban.args': '[@benutzername] [text]',
'command.ban.description': 'Einen Benutzer sperren',
diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts
index 4f13d6f83..2eaa79dbd 100644
--- a/examples/vite/src/i18n/it.ts
+++ b/examples/vite/src/i18n/it.ts
@@ -247,6 +247,7 @@ export const itTranslations = {
'channelListItem.video.ariaLabel': 'video',
'channelListItem.voiceMessage.ariaLabel': 'messaggio vocale',
'channelListItem.voted.text': '📊 {{votedBy}} ha votato: {{pollOptionText}}',
+ 'chat.reportLostConnection.reconnecting.text': 'Riconnessione…',
'chat.reportLostConnection.waitingNetwork.text': 'In attesa della rete…',
'command.ban.args': '[@nomeutente] [testo]',
'command.ban.description': 'Banna un utente',
diff --git a/examples/vite/src/icons.tsx b/examples/vite/src/icons.tsx
index 1b3ecbf38..cc97e568e 100644
--- a/examples/vite/src/icons.tsx
+++ b/examples/vite/src/icons.tsx
@@ -12,6 +12,19 @@ export const IconSidebar = createIcon(
/>,
);
+/** Two arcs and a dot — a signal/reachability mark, for the connection dev panel toggle. */
+export const IconConnection = createIcon(
+ 'IconConnection',
+ ,
+);
+
export const IconGear = createIcon(
'IconGear',
,
diff --git a/package.json b/package.json
index d3526a2f7..714661b19 100644
--- a/package.json
+++ b/package.json
@@ -131,7 +131,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
- "stream-chat": "^10.0.0-rc.10"
+ "stream-chat": "^10.0.0-rc.12"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
@@ -201,7 +201,7 @@
"react-dom": "^19.2.6",
"sass": "^1.100.0",
"semantic-release": "^25.0.3",
- "stream-chat": "10.0.0-rc.11",
+ "stream-chat": "^10.0.0-rc.12",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.1.3",
diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index e8de59a43..c6d74f422 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -78,20 +78,6 @@ export const Channel = (props: PropsWithChildren) => {
// One subscription per event, each released by its own handle, so subscribing and
// unsubscribing cannot drift apart and nothing has to filter events it never asked for.
const subscriptions = [
- // Reconnect hydration skips an active channel's message list -- a 25-message page would
- // perturb a scrolled-back window -- and leaves it to `channel.reload()`, which re-watches
- // sized to the loaded window. Nothing else calls it, so without this the list stays stale
- // and offline hard deletes are never reconciled: they arrive via no event.
- client.on('connection.recovered', async () => {
- if (channel.pendingDisposal) return;
- try {
- await channel.reload();
- } catch (error) {
- // The socket can drop again mid-reload. Keep the loaded window; the next recovery retries.
- console.warn('Failed to reload the channel after connection recovery', error);
- }
- }),
-
// Channel state is not normalized, so rather than hunting this user's references through it
// we re-query. Note what that does and does not do: it refreshes members, read state and
// watchers, but not the loaded messages -- the page it asks for is older than the window.
diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx
index 387bc5171..84827d605 100644
--- a/src/components/Channel/__tests__/Channel.test.tsx
+++ b/src/components/Channel/__tests__/Channel.test.tsx
@@ -25,7 +25,6 @@ import { useStateStore } from '../../../store';
import type { GenerateChannelOptions } from '../../../mock-builders';
import {
dispatchChannelTruncatedEvent,
- dispatchConnectionChangedEvent,
dispatchConnectionRecoveredEvent,
erroredPostApi,
generateChannel,
@@ -36,6 +35,7 @@ import {
getTestClientWithUser,
initClientWithChannels,
sendMessageApi,
+ setWSConnectionStatus,
useMockedApis,
} from '../../../mock-builders';
import { WithComponents } from '../../../context';
@@ -236,21 +236,6 @@ describe('Channel', () => {
expect(await findByText('children')).toBeInTheDocument();
});
- // should these 'on' tests actually test if the handler works?
- it('should add a connection recovery handler on the client on mount', async () => {
- const { channel, chatClient } = await setup();
- const clientOnSpy = vi.spyOn(chatClient, 'on');
-
- await renderComponent({ channel, chatClient });
-
- await waitFor(() =>
- expect(clientOnSpy).toHaveBeenCalledWith(
- 'connection.recovered',
- expect.any(Function),
- ),
- );
- });
-
it('releases every subscription it opened when the channel goes away', async () => {
// The invariant, rather than the wiring: whatever this component subscribes to, it
// unsubscribes from. Before the subscriptions were collected and released by their own handles,
@@ -288,41 +273,49 @@ describe('Channel', () => {
await renderComponent({ channel, chatClient });
// Wait for the mount effect to finish (it registers the event subscriptions)...
await waitFor(() =>
- expect(clientOnSpy).toHaveBeenCalledWith(
- 'connection.recovered',
- expect.any(Function),
- ),
+ expect(clientOnSpy).toHaveBeenCalledWith('user.deleted', expect.any(Function)),
);
// ...then confirm it did not mark read.
expect(markReadSpy).not.toHaveBeenCalled();
});
describe('connection recovery', () => {
- // The client's reconnect hydration skips re-seeding the message list of an `active` channel
- // (Channel marks it active while mounted) and delegates that window to `channel.reload()`.
- // Nothing else calls it, so these pin the SDK component as the thing that does.
- it('reloads the channel when the connection is recovered', async () => {
+ // `ConnectionRecoveryManager` reloads every active channel and *then* dispatches
+ // `connection.recovered`. `Channel` marks its channel active while mounted, so handling that
+ // event here as well would reload every open channel twice per reconnect — two full `watch()`
+ // requests. `Channel.reload()`'s `_reloading` flag would not collapse the pair: it is a
+ // re-entrancy guard and has already reset by the time the event is dispatched.
+ it('reloads an open channel exactly once per reconnect', async () => {
const { channel, chatClient } = await setup();
await renderComponent({ channel, chatClient });
+ await waitFor(() => expect(channel.active).toBe(true));
const reloadSpy = vi.spyOn(channel, 'reload').mockResolvedValue(undefined);
+ chatClient.connectionRecovery.registerSubscriptions();
+ // A whole reconnect, driven from the socket's status store rather than by dispatching the
+ // recovery event directly — that is what exercises both would-be reloaders. Down first,
+ // because recovery ignores a first connect: nothing was loaded to fall behind.
await act(async () => {
- dispatchConnectionRecoveredEvent(chatClient);
- await Promise.resolve();
+ setWSConnectionStatus(chatClient, false);
+ setWSConnectionStatus(chatClient, true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
});
- await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1));
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- it('does not reload a channel that is pending disposal', async () => {
+ it('leaves the reload to the client, so it happens even without this component', async () => {
+ // The reconciliation React's own reload existed for — a hard delete that happened offline
+ // arrives via no event, so only a re-query surfaces it — still happens, because the client
+ // reloads active channels itself. This pins that it is the client doing it.
const { channel, chatClient } = await setup();
await renderComponent({ channel, chatClient });
+ await waitFor(() => expect(channel.active).toBe(true));
const reloadSpy = vi.spyOn(channel, 'reload').mockResolvedValue(undefined);
- // `reload()` goes through `getClient()`, which throws once the channel is pending disposal.
- channel.pendingDisposal = true;
+ // `Channel` has no `connection.recovered` handler, so this alone must do nothing.
await act(async () => {
dispatchConnectionRecoveredEvent(chatClient);
await Promise.resolve();
@@ -331,23 +324,27 @@ describe('Channel', () => {
expect(reloadSpy).not.toHaveBeenCalled();
});
- it('keeps rendering when the reload fails', async () => {
+ it('keeps rendering when the reload fails during a reconnect', async () => {
+ // The guarantee the old React-side error handling provided, now held somewhere better: the
+ // client reloads active channels with `Promise.allSettled`, so a socket that flaps back down
+ // mid-reload cannot throw into this component at all.
const { channel, chatClient } = await setup();
const { container } = await renderComponent({ channel, chatClient });
+ await waitFor(() => expect(channel.active).toBe(true));
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const reloadSpy = vi
.spyOn(channel, 'reload')
.mockRejectedValue(new Error('socket flapped'));
+ chatClient.connectionRecovery.registerSubscriptions();
await act(async () => {
- dispatchConnectionRecoveredEvent(chatClient);
- await Promise.resolve();
+ setWSConnectionStatus(chatClient, false);
+ setWSConnectionStatus(chatClient, true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
});
- await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1));
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
expect(container.querySelector('.str-chat__channel')).toBeInTheDocument();
- warnSpy.mockRestore();
});
});
@@ -363,7 +360,7 @@ describe('Channel', () => {
// a re-render that reads channel state must not throw
// (channel.lastRead() throws once the client is disconnected)
await act(async () => {
- dispatchConnectionChangedEvent(chatClient, false);
+ setWSConnectionStatus(chatClient, false);
await Promise.resolve();
});
@@ -386,7 +383,7 @@ describe('Channel', () => {
channel.pendingDisposal = true;
await act(async () => {
- dispatchConnectionChangedEvent(chatClient, false);
+ setWSConnectionStatus(chatClient, false);
await Promise.resolve();
});
diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx
index 3c5d37644..8f6a6836a 100644
--- a/src/components/Chat/__tests__/Chat.test.tsx
+++ b/src/components/Chat/__tests__/Chat.test.tsx
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
-import type { OwnUserResponse } from 'stream-chat';
+import type { OwnUserResponse, StreamChat } from 'stream-chat';
import { ChannelPaginator } from 'stream-chat';
import { Chat } from '..';
@@ -14,10 +14,10 @@ import { Streami18n } from '../../../i18n';
import type { Notification } from 'stream-chat';
import type { UserMuteResponse } from 'stream-chat';
import {
- dispatchConnectionChangedEvent,
dispatchNotificationMutesUpdated,
getTestClient,
getTestClientWithUser,
+ setWSConnectionStatus,
} from '../../../mock-builders';
const ChatContextConsumer = ({ fn }) => {
@@ -376,37 +376,210 @@ describe('Chat', () => {
});
describe('connection notifications', () => {
- it('publishes and removes system connection-lost notification on connection changes', async () => {
- const client = getTestClient();
- let connectionLostNotification;
-
+ /**
+ * Takes the socket down and waits out the window the banner holds a drop for.
+ *
+ * The client publishes every transition as it happens; deciding a drop has lasted long enough to
+ * be worth telling a person about is the banner's job, so a test that wants the banner has to
+ * let that window pass.
+ */
+ /** How long the banner sits on a drop, which is configuration rather than a fixed number. */
+ const holdWindow = (client: StreamChat) =>
+ client.wsConnection.config.offlineNotificationDisplayDelayMs;
+
+ const dropSocket = (client: StreamChat) => {
+ vi.useFakeTimers();
+ try {
+ act(() => setWSConnectionStatus(client, false));
+ act(() => {
+ vi.advanceTimersByTime(holdWindow(client));
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ };
+
+ it('keeps the notification when the socket drops before i18n has initialized', async () => {
+ // `Streami18n.init()` is asynchronous and replaces `t`, which re-runs the subscription effect.
+ // Dismissal must therefore be scoped to the mount, not to that effect's cleanup, or a drop
+ // inside the init window leaves no banner exactly when one is most wanted: an offline app
+ // launch, a captive portal, an expired token.
+ const client = await getTestClientWithUser();
render(
,
);
-
- expect(client.notifications.notifications).toHaveLength(0);
-
- act(() => dispatchConnectionChangedEvent(client, false));
- await waitFor(() => {
- connectionLostNotification = client.notifications.notifications.find(
+ const chatNotifications = () =>
+ client.notifications.notifications.filter(
(notification) => notification.origin.emitter === 'Chat',
);
- expect(connectionLostNotification).toBeDefined();
+
+ // Deliberately not awaiting anything first — the drop lands inside the init window.
+ dropSocket(client);
+ expect(chatNotifications()).toHaveLength(1);
+
+ // Long enough for `init()` to resolve and `t` to be replaced.
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
});
- expect(connectionLostNotification.message).toBe('Waiting for network…');
- expect(connectionLostNotification.tags).toEqual(['system']);
+ expect(chatNotifications()).toHaveLength(1);
+ });
- act(() => dispatchConnectionChangedEvent(client, true));
- await waitFor(() => {
- expect(
- client.notifications.notifications.find(
- (notification) => notification.origin.emitter === 'Chat',
- ),
- ).toBeUndefined();
+ /**
+ * The device losing its network and the socket dying are different facts, so they get different
+ * copy. Publishing "Waiting for network…" off the socket alone — which is what this did — told
+ * users their network was down when the server had closed the socket, the token had expired or a
+ * health check had timed out on working Wi-Fi.
+ */
+ const chatNotificationsOf = (client: StreamChat) =>
+ client.notifications.notifications.filter(
+ (notification) => notification.origin.emitter === 'Chat',
+ );
+
+ it('says reconnecting, not offline, when the socket dies on a working network', async () => {
+ const client = await getTestClientWithUser();
+ render(
+
+
+ ,
+ );
+ // jsdom is a browser, so the built-in reporter has already reported the network as up.
+ expect(client.networkConnection.isOnline).toBe(true);
+
+ dropSocket(client);
+
+ await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1));
+ expect(chatNotificationsOf(client)[0].message).toBe('Reconnecting…');
+ expect(chatNotificationsOf(client)[0].tags).toEqual(['system']);
+ });
+
+ it('says the network is down when the device reports no network', async () => {
+ const client = await getTestClientWithUser();
+ render(
+
+
+ ,
+ );
+
+ act(() => client.networkConnection.setStatus(false));
+
+ await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1));
+ expect(chatNotificationsOf(client)[0].message).toBe('Waiting for network…');
+ });
+
+ it('swaps to the network message when the network drops while reconnecting', async () => {
+ const client = await getTestClientWithUser();
+ render(
+
+
+ ,
+ );
+
+ dropSocket(client);
+ await waitFor(() =>
+ expect(chatNotificationsOf(client)[0].message).toBe('Reconnecting…'),
+ );
+
+ act(() => client.networkConnection.setStatus(false));
+
+ // One banner throughout, with the more specific message replacing the general one.
+ await waitFor(() =>
+ expect(chatNotificationsOf(client)[0].message).toBe('Waiting for network…'),
+ );
+ expect(chatNotificationsOf(client)).toHaveLength(1);
+ });
+
+ it('publishes immediately when the client is already offline at mount', async () => {
+ // The status is read on mount rather than waited for, so a client that is already offline when
+ // the banner mounts says so.
+ const client = await getTestClientWithUser();
+ client.networkConnection.setStatus(false);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1));
+ expect(chatNotificationsOf(client)[0].message).toBe('Waiting for network…');
+ });
+
+ it('clears on recovery', async () => {
+ const client = await getTestClientWithUser();
+ render(
+
+
+ ,
+ );
+
+ dropSocket(client);
+ await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1));
+
+ act(() => setWSConnectionStatus(client, true));
+
+ await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(0));
+ });
+
+ it('respects a configured hold window', async () => {
+ // The wait is configuration rather than a fixed number, so an integrator can shorten it, or
+ // switch the wait off with zero, without replacing the hook. Zero still defers to the next
+ // task, which is why this advances timers rather than asserting synchronously.
+ const client = await getTestClientWithUser();
+ client.config.set({
+ client: { wsConnection: { offlineNotificationDisplayDelayMs: 0 } },
});
+ render(
+
+
+ ,
+ );
+
+ vi.useFakeTimers();
+ try {
+ act(() => setWSConnectionStatus(client, false));
+ act(() => {
+ vi.advanceTimersByTime(0);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+
+ expect(chatNotificationsOf(client)).toHaveLength(1);
+ expect(chatNotificationsOf(client)[0].message).toBe('Reconnecting…');
+ });
+
+ it('shows nothing for a drop the socket recovers from inside the window', async () => {
+ // The reason the banner holds a drop at all. The socket retries on its own and most drops
+ // resolve in well under a second; announcing those makes a working application look broken.
+ const client = await getTestClientWithUser();
+ render(
+
+
+ ,
+ );
+
+ vi.useFakeTimers();
+ try {
+ act(() => setWSConnectionStatus(client, false));
+ act(() => {
+ vi.advanceTimersByTime(holdWindow(client) - 1);
+ });
+ // Nothing yet, and nothing later either: coming back cancels the held drop rather than
+ // showing it and then removing it.
+ expect(chatNotificationsOf(client)).toHaveLength(0);
+
+ act(() => setWSConnectionStatus(client, true));
+ act(() => {
+ vi.advanceTimersByTime(holdWindow(client) * 2);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+
+ expect(chatNotificationsOf(client)).toHaveLength(0);
});
it('uses NotificationAnnouncer from ComponentContext', async () => {
diff --git a/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx b/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx
new file mode 100644
index 000000000..2ecd3b29c
--- /dev/null
+++ b/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx
@@ -0,0 +1,200 @@
+import React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { NetworkStatusReporter, StreamChat } from 'stream-chat';
+
+import { Chat } from '../../Chat';
+import { useNetworkConnectionState } from '../useNetworkConnectionState';
+import { useNetworkConnectionStateSelector } from '../useNetworkConnectionState';
+import { useWSConnectionState } from '../useWSConnectionState';
+import {
+ getTestClient,
+ getTestClientWithUser,
+ setWSConnectionStatus,
+} from '../../../../mock-builders';
+
+/** An integrator's registration function, of the shape a platform API would be wrapped in. */
+const platformListener = () => {
+ let report: ((isOnline: boolean) => void) | undefined;
+ const reporter: NetworkStatusReporter = (onStatusChange) => {
+ report = onStatusChange;
+ return vi.fn();
+ };
+ return {
+ report: (isOnline: boolean) => {
+ if (!report) throw new Error('the reporter was never installed');
+ act(() => report?.(isOnline));
+ },
+ reporter,
+ };
+};
+
+const renderUnderChat = (client: StreamChat, ui: React.ReactNode) =>
+ render({ui});
+
+describe('connection state hooks', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('useNetworkConnectionState', () => {
+ it('starts online in jsdom, because jsdom is a browser', async () => {
+ // Worth stating explicitly: the SDK installs its built-in browser reporter whenever
+ // `window.addEventListener` exists and `navigator.onLine` is a boolean — which is true in
+ // jsdom. So a React test starts with a *known* status, not an unknown one, and the
+ // `undefined` case has to be arranged deliberately (see the next test).
+ const client = await getTestClientWithUser({ id: 'me' });
+ const Consumer = () => {
+ const state = useNetworkConnectionState();
+ return {String(state?.isOnline)}
;
+ };
+
+ renderUnderChat(client, );
+
+ expect(screen.getByTestId('v')).toHaveTextContent('true');
+ });
+
+ it('surfaces isOnline === undefined as-is until something has reported', () => {
+ // The React Native shape: `navigator` has no boolean `onLine`, so the browser reporter cannot
+ // be installed and the socket-derived stand-in takes over. That one reports nothing until the
+ // socket has been up once, so a client that has never connected is honestly unknown rather
+ // than coerced to either answer. Stubbed before the client is constructed, because a reporter
+ // installs and reports during construction.
+ vi.stubGlobal('navigator', { userAgent: 'ReactNative' });
+ const client = getTestClient();
+ const Consumer = () => {
+ const state = useNetworkConnectionState();
+ return {JSON.stringify(state?.isOnline ?? 'unknown')}
;
+ };
+
+ renderUnderChat(client, );
+
+ // Not coerced to false, and nothing crashed on the absent value.
+ expect(screen.getByTestId('v')).toHaveTextContent('"unknown"');
+ vi.unstubAllGlobals();
+ });
+
+ it('follows the socket where no platform reporter can be installed', async () => {
+ // What an integration that forgets to install one now gets: coarse rather than absent. The
+ // two facts cannot disagree under the stand-in, which is exactly why a real reporter is still
+ // worth installing.
+ vi.stubGlobal('navigator', { userAgent: 'ReactNative' });
+ const client = await getTestClientWithUser({ id: 'me' });
+ const Consumer = () => {
+ const state = useNetworkConnectionState();
+ return {String(state?.isOnline)}
;
+ };
+
+ renderUnderChat(client, );
+
+ // The fixture marks the socket up, and the stand-in reports that as the device's status.
+ expect(screen.getByTestId('v')).toHaveTextContent('true');
+
+ act(() => setWSConnectionStatus(client, false));
+
+ expect(screen.getByTestId('v')).toHaveTextContent('false');
+ vi.unstubAllGlobals();
+ });
+
+ it('re-renders when the reporter reports a change', async () => {
+ const client = await getTestClientWithUser({ id: 'me' });
+ const platform = platformListener();
+ client.config.set({
+ client: { networkConnection: { statusReporter: platform.reporter } },
+ });
+ const Consumer = () => {
+ const state = useNetworkConnectionState();
+ return {String(state?.isOnline)}
;
+ };
+
+ renderUnderChat(client, );
+ // Replacing the reporter does not reset the last known status — an edge is not a state — so
+ // this starts from what the browser reporter already reported.
+ expect(screen.getByTestId('v')).toHaveTextContent('true');
+
+ platform.report(false);
+ expect(screen.getByTestId('v')).toHaveTextContent('false');
+
+ platform.report(true);
+ expect(screen.getByTestId('v')).toHaveTextContent('true');
+ });
+ });
+
+ describe('useNetworkConnectionStateSelector', () => {
+ it('does not re-render when an unselected field changes', async () => {
+ const client = await getTestClientWithUser({ id: 'me' });
+ const platform = platformListener();
+ client.config.set({
+ client: { networkConnection: { statusReporter: platform.reporter } },
+ });
+ const renders = vi.fn();
+ const Consumer = () => {
+ // `lastOnlineAt` and `lastOfflineAt` also change on every report; this selects neither.
+ const selected = useNetworkConnectionStateSelector(({ isOnline }) => ({
+ isOnline,
+ }));
+ renders();
+ return {String(selected?.isOnline)}
;
+ };
+
+ renderUnderChat(client, );
+ const initial = renders.mock.calls.length;
+
+ // `false` is a real change from the browser reporter's initial `true`.
+ platform.report(false);
+ expect(renders.mock.calls.length).toBeGreaterThan(initial);
+ const afterChange = renders.mock.calls.length;
+
+ // A repeat of the same status is ignored by the observer, so nothing re-renders.
+ platform.report(false);
+ expect(renders.mock.calls.length).toBe(afterChange);
+ });
+ });
+
+ describe('the two hooks together', () => {
+ it('distinguishes network-up/socket-down from network-down/socket-up', async () => {
+ // If these two are not distinguishable, the feature has not delivered its point.
+ const client = await getTestClientWithUser({ id: 'me' });
+ const platform = platformListener();
+ client.config.set({
+ client: { networkConnection: { statusReporter: platform.reporter } },
+ });
+ const Consumer = () => {
+ // No aliasing needed: the socket reports `isHealthy` and the device `isOnline`, so the two
+ // would shadow one with the other.
+ const { isOnline: networkOnline } = useNetworkConnectionState() ?? {};
+ const { isHealthy: socketOnline } = useWSConnectionState() ?? {};
+ return (
+ {`network=${String(networkOnline)} socket=${String(socketOnline)}`}
+ );
+ };
+
+ renderUnderChat(client, );
+ // The mock client marks the socket up, so this is the interesting asymmetry: a device with no
+ // network while the socket still believes it is fine.
+ platform.report(false);
+ expect(screen.getByTestId('v')).toHaveTextContent('network=false socket=true');
+
+ // And the reverse: network fine, socket down.
+ platform.report(true);
+ act(() => {
+ client.wsConnection.state.partialNext({ isHealthy: false });
+ });
+ expect(screen.getByTestId('v')).toHaveTextContent('network=true socket=false');
+ });
+ });
+
+ describe('useWSConnectionState', () => {
+ it('reports the socket status', async () => {
+ const client = await getTestClientWithUser({ id: 'me' });
+ const Consumer = () => {
+ const state = useWSConnectionState();
+ return {String(state?.isHealthy)}
;
+ };
+
+ renderUnderChat(client, );
+
+ expect(screen.getByTestId('v')).toHaveTextContent('true');
+ });
+ });
+});
diff --git a/src/components/Chat/hooks/useNetworkConnectionState.ts b/src/components/Chat/hooks/useNetworkConnectionState.ts
new file mode 100644
index 000000000..83238b9ba
--- /dev/null
+++ b/src/components/Chat/hooks/useNetworkConnectionState.ts
@@ -0,0 +1,50 @@
+import type { NetworkConnectionState } from 'stream-chat';
+
+import { useChatContext } from '../../../context/ChatContext';
+import { useStateStore } from '../../../store';
+
+const identity = (state: NetworkConnectionState) => state;
+
+/**
+ * The **device's** network status, as reported by the platform listener registered on
+ * `client.networkConnection`.
+ *
+ * Not the same fact as {@link useWSConnectionState}, and the difference is the point: a socket dies on
+ * a working network (a server close, an expired token, a health-check timeout), and a device drops
+ * while the socket has not noticed yet. Use this for "you're offline"; use the WebSocket hook for
+ * "reconnecting…".
+ *
+ * `isOnline` has **three** states. `undefined` means *unknown* — nobody has told the client, because
+ * no reporter is installed or one is installed and has not reported yet. So a guard must test
+ * `isOnline === false`; `!isOnline` is also true when the answer is unknown and would claim "offline"
+ * on any host without a reporter. (The WebSocket store's `isOnline` is always a boolean, so `!` is
+ * fine there.)
+ *
+ * Must run under `ChatProvider`, e.g. from a child of ``.
+ */
+export const useNetworkConnectionState = (): NetworkConnectionState | undefined => {
+ const { client } = useChatContext();
+ return useStateStore(client?.networkConnection.state, identity);
+};
+
+/**
+ * {@link useNetworkConnectionState} narrowed to what a component actually reads, so it re-renders
+ * only when that changes.
+ *
+ * The selector must return a flat object or tuple — it is shallow-compared on its own keys.
+ *
+ * @example
+ * ```tsx
+ * const isOffline = useNetworkConnectionStateSelector(
+ * ({ isOnline }) => ({ isOffline: isOnline === false }),
+ * )?.isOffline;
+ * ```
+ */
+export const useNetworkConnectionStateSelector = <
+ O extends Readonly | Readonly>,
+>(
+ selector: (state: NetworkConnectionState) => O,
+): O | undefined => {
+ const { client } = useChatContext();
+ return useStateStore(client?.networkConnection.state, selector);
+};
diff --git a/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts b/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts
index e916f29da..439d7e876 100644
--- a/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts
+++ b/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts
@@ -1,54 +1,189 @@
-import { useEffect, useRef } from 'react';
-import type { EventPayload } from 'stream-chat';
+import { useCallback, useEffect, useRef } from 'react';
+import type { ConnectionType } from 'stream-chat';
import { useChatContext } from '../../../context/ChatContext';
import { useTranslationContext } from '../../../context/TranslationContext';
import { useNotificationApi } from '../../Notifications/hooks/useNotificationApi';
/**
- * Publishes a persistent system notification while the client is offline and removes it when
- * back online. Must run under `ChatProvider` and `TranslationProvider` (e.g. from a child of ``).
+ * Publishes a persistent system notification while this client cannot reach Stream, and removes it
+ * when it can again. Must run under `ChatProvider` and `TranslationProvider` (e.g. from a child of
+ * ``).
+ *
+ * **Two facts, two messages.** The device losing its network and this client's WebSocket dying are
+ * different things, and they disagree in both directions — a socket dies on working Wi-Fi when the
+ * server closes it, the token expires or a health check times out. So both are read, and the wording
+ * follows:
+ *
+ * - the device reports no network → the network message
+ * - the network is up (or unknown) and the socket is down → the reconnecting message
+ *
+ * Choosing that grouping is a copy decision rather than a fact about connectivity, which is why the
+ * SDK publishes no combined status and why the decision is made here, in the component that renders
+ * the copy.
+ *
+ * Both signals are subscribed **imperatively** rather than through the `useNetworkConnectionState` /
+ * `useWSConnectionState` hooks: `` calls this, and re-rendering the whole tree on every network
+ * flap is exactly what those hooks exist to let consumers avoid.
+ *
+ * **A drop is held before it is shown**, for
+ * `client.wsConnection.config.offlineNotificationDisplayDelayMs`, and dropped entirely if the socket
+ * returns inside that window: the socket retries on its own and most drops resolve in well under a
+ * second, so showing them all makes a working application look broken. The length is configuration
+ * rather than a constant here, so it can be changed without replacing this hook. The device's network
+ * is not held back — a browser reports it accurately and it does not flap the way a socket does.
*/
export const useReportLostConnectionSystemNotification = () => {
const { t } = useTranslationContext();
const { client } = useChatContext();
const { addSystemNotification, removeNotification } = useNotificationApi();
- const connectionLostNotificationIdRef = useRef(null);
+ const notificationIdRef = useRef(null);
+ /** Outside the effect, so re-establishing the subscriptions does not republish what is showing. */
+ const reasonRef = useRef(null);
+ /**
+ * The socket's status as last *shown*, which is not `client.wsConnection.state`: a drop the store
+ * has already published may still be inside its holding window here.
+ *
+ * Outside the effect because that re-runs whenever `t` is replaced, and re-seeding from the store
+ * would discard a drop still being held. Seeded once, on mount, so an application that starts up
+ * with no connection says so immediately rather than after the window.
+ */
+ const socketOnlineRef = useRef(null);
+ /** The drop being held, so the socket returning can cancel it. */
+ const heldDropRef = useRef | null>(null);
+
+ const cancelHeldDrop = useCallback(() => {
+ if (heldDropRef.current === null) return;
+ clearTimeout(heldDropRef.current);
+ heldDropRef.current = null;
+ }, []);
+
+ const dismissConnectionLostNotification = useCallback(() => {
+ if (!notificationIdRef.current) return;
+ removeNotification(notificationIdRef.current);
+ notificationIdRef.current = null;
+ reasonRef.current = null;
+ }, [removeNotification]);
+
+ /**
+ * Dismissal is scoped to the mount, not to the subscriptions below.
+ *
+ * Their dependencies change for reasons that have nothing to do with the connection — `t` is
+ * replaced when `Streami18n.init()` resolves, asynchronously. When dismissal was part of that
+ * effect's cleanup, a socket dropping before init finished had its notification published and then
+ * immediately removed, leaving no banner on an offline app launch or behind a captive portal.
+ */
+ useEffect(
+ () => () => {
+ cancelHeldDrop();
+ dismissConnectionLostNotification();
+ },
+ [cancelHeldDrop, dismissConnectionLostNotification],
+ );
useEffect(() => {
if (!t || !client) return;
- const dismissConnectionLostNotification = () => {
- if (!connectionLostNotificationIdRef.current) return;
- removeNotification(connectionLostNotificationIdRef.current);
- connectionLostNotificationIdRef.current = null;
+ // Keyed by `ConnectionType` rather than a local union, so a connection type added to the client
+ // breaks `yarn build` here (`tsconfig.lib.json`) until someone decides what the banner should say
+ // about it. Verified by widening the type: `Property 'sse' is missing`. Note `yarn types` does
+ // *not* catch it — without `strictNullChecks` the absent key is `undefined`, which is assignable
+ // to `string`.
+ const messages: Record = {
+ network: t('chat.reportLostConnection.waitingNetwork.text', 'Waiting for network…'),
+ ws: t('chat.reportLostConnection.reconnecting.text', 'Reconnecting…'),
};
- const handleConnectionChanged = ({ online }: EventPayload<'connection.changed'>) => {
- if (!online) {
- if (connectionLostNotificationIdRef.current) return;
-
- connectionLostNotificationIdRef.current = addSystemNotification({
- duration: 0,
- emitter: 'Chat',
- message: t(
- 'chat.reportLostConnection.waitingNetwork.text',
- 'Waiting for network…',
- ),
- severity: 'loading',
- type: 'system:network:connection:lost',
- });
- return;
- }
+ const show = (reason: ConnectionType) => {
+ if (reasonRef.current === reason) return;
+ // Replaced rather than left alone: a network drop while "Reconnecting…" is showing needs the
+ // more specific message.
+ dismissConnectionLostNotification();
+ reasonRef.current = reason;
+ notificationIdRef.current = addSystemNotification({
+ duration: 0,
+ emitter: 'Chat',
+ message: messages[reason],
+ severity: 'loading',
+ // One type for both messages, deliberately. Consumers filter banners on it — the SDK's own
+ // cookbook recipe does — so splitting it would silently stop those filters seeing the socket
+ // case. The `network` in the name is historical; the message is what was wrong.
+ type: 'system:network:connection:lost',
+ });
+ };
+
+ if (socketOnlineRef.current === null) {
+ socketOnlineRef.current = client.wsConnection.isHealthy;
+ }
+
+ // `=== false` for the network, never `!networkOnline`: `undefined` means nobody has told us, and
+ // on a host whose reporter cannot answer that must not read as offline. The socket's is a plain
+ // boolean.
+ let networkOnline = client.networkConnection.isOnline;
+ const sync = () => {
+ if (networkOnline === false) return show('network');
+ if (!socketOnlineRef.current) return show('ws');
dismissConnectionLostNotification();
};
- const subscription = client.on('connection.changed', handleConnectionChanged);
+ const unsubscribeNetwork = client.networkConnection.state.subscribeWithSelector(
+ ({ isOnline }) => ({ isOnline }),
+ ({ isOnline }) => {
+ networkOnline = isOnline;
+ sync();
+ },
+ );
+
+ // `subscribeWithSelector` calls back immediately with the current value. That call is the seed
+ // above rather than a transition, and holding it would delay the banner on an application that
+ // starts up with no connection.
+ let seeded = false;
+
+ const unsubscribeSocket = client.wsConnection.state.subscribeWithSelector(
+ ({ isHealthy }) => ({ isHealthy }),
+ ({ isHealthy }) => {
+ if (!seeded) {
+ seeded = true;
+ return;
+ }
+
+ if (isHealthy) {
+ // Coming back is not held: there is no reason to sit on good news, and a drop still inside
+ // its window is cancelled rather than shown, so a brief flap produces nothing at all.
+ cancelHeldDrop();
+ socketOnlineRef.current = true;
+ sync();
+ return;
+ }
+
+ // Already holding one. A second drop without an intervening recovery cannot happen, but a
+ // re-subscription during the window can, and restarting the timer would extend the wait.
+ if (heldDropRef.current !== null) return;
+
+ heldDropRef.current = setTimeout(() => {
+ heldDropRef.current = null;
+ socketOnlineRef.current = false;
+ sync();
+ // Read when the drop happens rather than captured, so a change to it reaches the next
+ // drop without this effect being re-established.
+ }, client.wsConnection.config.offlineNotificationDisplayDelayMs);
+ },
+ );
+
+ // Read the current state rather than waiting for a transition — a client already offline when
+ // this mounts showed nothing at all before.
+ sync();
return () => {
- subscription.unsubscribe();
- dismissConnectionLostNotification();
+ unsubscribeNetwork();
+ unsubscribeSocket();
};
- }, [addSystemNotification, client, removeNotification, t]);
+ }, [
+ addSystemNotification,
+ cancelHeldDrop,
+ client,
+ dismissConnectionLostNotification,
+ t,
+ ]);
};
diff --git a/src/components/Chat/hooks/useWSConnectionState.ts b/src/components/Chat/hooks/useWSConnectionState.ts
new file mode 100644
index 000000000..2a78d88ee
--- /dev/null
+++ b/src/components/Chat/hooks/useWSConnectionState.ts
@@ -0,0 +1,30 @@
+import type { WSConnectionState } from 'stream-chat';
+
+import { useChatContext } from '../../../context/ChatContext';
+import { useStateStore } from '../../../store';
+
+const identity = (state: WSConnectionState) => state;
+
+/**
+ * This client's **WebSocket** status — whether the realtime connection is up, and the connection id
+ * the server keys channel watches by.
+ *
+ * Not the device's network: see {@link useNetworkConnectionState}. Use this for "reconnecting…", for
+ * disabling a composer, or for anything that needs the realtime connection specifically.
+ *
+ * The store is written on **every** transition, including `client.closeConnection()` — the documented
+ * mobile backgrounding path — and it publishes a drop the moment it happens. If you are rendering a
+ * "connection lost" banner, hold a drop for `client.wsConnection.config.offlineNotificationDisplayDelayMs` before showing it and
+ * cancel it if the socket returns inside that window, which is what `` does: the socket retries
+ * on its own, and most drops resolve in well under a second.
+ *
+ * The connection id is not here: it lives on `client.connectionIdManager`, which holds any request
+ * that watches or subscribes to presence until one exists. Its `isHealthy` is always a boolean, unlike the
+ * network store's three-state `isOnline`.
+ *
+ * Must run under `ChatProvider`, e.g. from a child of ``.
+ */
+export const useWSConnectionState = (): WSConnectionState | undefined => {
+ const { client } = useChatContext();
+ return useStateStore(client?.wsConnection.state, identity);
+};
diff --git a/src/components/Chat/index.ts b/src/components/Chat/index.ts
index 68e891507..e27bc6270 100644
--- a/src/components/Chat/index.ts
+++ b/src/components/Chat/index.ts
@@ -2,3 +2,5 @@ export * from './Chat';
export * from './hooks/useChat';
export * from './hooks/useReportLostConnectionSystemNotification';
export * from './hooks/useCreateChatClient';
+export * from './hooks/useNetworkConnectionState';
+export * from './hooks/useWSConnectionState';
diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json
index 220a3d00f..fcf793aa0 100644
--- a/src/i18n/__tests__/catalog.fixture.json
+++ b/src/i18n/__tests__/catalog.fixture.json
@@ -183,6 +183,7 @@
"channelListItem.video.ariaLabel": "video",
"channelListItem.voiceMessage.ariaLabel": "voice message",
"channelListItem.voted.text": "📊 {{votedBy}} voted: {{pollOptionText}}",
+ "chat.reportLostConnection.reconnecting.text": "Reconnecting…",
"chat.reportLostConnection.waitingNetwork.text": "Waiting for network…",
"command.ban.args": "[@username] [text]",
"command.ban.description": "Ban a user",
diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts
index fb5a4730f..a83d9be02 100644
--- a/src/i18n/keys.ts
+++ b/src/i18n/keys.ts
@@ -194,6 +194,7 @@ export type TranslationCatalog = {
'channelListItem.video.ariaLabel': 'video';
'channelListItem.voiceMessage.ariaLabel': 'voice message';
'channelListItem.voted.text': '📊 {{votedBy}} voted: {{pollOptionText}}';
+ 'chat.reportLostConnection.reconnecting.text': 'Reconnecting…';
'chat.reportLostConnection.waitingNetwork.text': 'Waiting for network…';
'command.ban.args': '[@username] [text]';
'command.ban.description': 'Ban a user';
diff --git a/src/mock-builders/event/connectionChanged.ts b/src/mock-builders/event/connectionChanged.ts
deleted file mode 100644
index 7bc01e698..000000000
--- a/src/mock-builders/event/connectionChanged.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { fromPartial } from '@total-typescript/shoehorn';
-import type { Event, StreamChat } from 'stream-chat';
-
-export default (client: StreamChat, online: boolean) => {
- client.dispatchEvent(
- fromPartial({
- online,
- type: 'connection.changed',
- }),
- );
-};
diff --git a/src/mock-builders/event/connectionRecovered.ts b/src/mock-builders/event/connectionRecovered.ts
index a311ff7b6..75359cfb9 100644
--- a/src/mock-builders/event/connectionRecovered.ts
+++ b/src/mock-builders/event/connectionRecovered.ts
@@ -1,9 +1,16 @@
import { fromPartial } from '@total-typescript/shoehorn';
-import type { Event, StreamChat } from 'stream-chat';
+import type { ConnectionType, Event, StreamChat } from 'stream-chat';
-export default (client: StreamChat) => {
+/**
+ * Dispatches `connection.recovered`.
+ *
+ * `connection` defaults to `'ws'`, which is also the only value the client actually dispatches today —
+ * recovery is about the socket being back and its watches re-established.
+ */
+export default (client: StreamChat, connection: ConnectionType = 'ws') => {
client.dispatchEvent(
fromPartial({
+ connection,
type: 'connection.recovered',
}),
);
diff --git a/src/mock-builders/event/connectionStatus.ts b/src/mock-builders/event/connectionStatus.ts
new file mode 100644
index 000000000..9dba9f3d2
--- /dev/null
+++ b/src/mock-builders/event/connectionStatus.ts
@@ -0,0 +1,36 @@
+import type { StreamChat } from 'stream-chat';
+
+/**
+ * Drives this client's WebSocket status, the way a real socket coming up or going down would.
+ *
+ * Connectivity is published as `client.wsConnection.state` and `client.networkConnection.state`, so
+ * driving a test means writing the store.
+ *
+ * Through the public store rather than the socket's internal `_setStatus`, which
+ * `no-underscore-dangle` rightly rejects. Timestamps are stamped too, since a status without one is a
+ * state the real socket never produces.
+ *
+ * Anything reading this through the `` banner holds a drop for
+ * `client.wsConnection.config.offlineNotificationDisplayDelayMs` before showing it, so a test
+ * asserting on that banner has to advance timers.
+ */
+export const setWSConnectionStatus = (
+ client: StreamChat,
+ isHealthy: boolean,
+ connectionId = 'mock-connection-id',
+) => {
+ client.wsConnection.state.partialNext(
+ isHealthy
+ ? { isHealthy, lastHealthyAt: new Date() }
+ : { isHealthy, lastUnhealthyAt: new Date() },
+ );
+ // The id is separate state with its own lifecycle: published when the socket announces itself,
+ // dropped when it goes down so no request carries one the server has closed.
+ if (isHealthy) client.connectionIdManager.resolveConnectionId(connectionId);
+ else client.connectionIdManager.invalidate();
+};
+
+/** Reports the device's network status, as a platform reporter would. */
+export const setNetworkStatus = (client: StreamChat, isOnline: boolean) => {
+ client.networkConnection.setStatus(isOnline);
+};
diff --git a/src/mock-builders/event/index.ts b/src/mock-builders/event/index.ts
index f6a118519..934339b1e 100644
--- a/src/mock-builders/event/index.ts
+++ b/src/mock-builders/event/index.ts
@@ -3,7 +3,7 @@ export { default as dispatchChannelHiddenEvent } from './channelHidden';
export { default as dispatchChannelTruncatedEvent } from './channelTruncated';
export { default as dispatchChannelUpdatedEvent } from './channelUpdated';
export { default as dispatchChannelVisibleEvent } from './channelVisible';
-export { default as dispatchConnectionChangedEvent } from './connectionChanged';
+export { setNetworkStatus, setWSConnectionStatus } from './connectionStatus';
export { default as dispatchConnectionRecoveredEvent } from './connectionRecovered';
export { default as dispatchMessageDeletedEvent } from './messageDeleted';
export { default as dispatchMessageNewEvent } from './messageNew';
diff --git a/src/mock-builders/index.ts b/src/mock-builders/index.ts
index 0e1810d7f..80260c47e 100644
--- a/src/mock-builders/index.ts
+++ b/src/mock-builders/index.ts
@@ -8,12 +8,28 @@ const token = 'dummy_token';
const connectUser = (client: StreamChat, user: Partial) =>
new Promise((resolve) => {
- client['connectionId'] = 'dumm_connection_id';
+ // Mark the socket up, which is what "connected" means to the client: `channel.watch()` and
+ // `client.queryChannels()` wait for a live connection instead of degrading to `watch: false`.
+ //
+ // Written through the public store rather than the socket's internal `_setStatus`, which
+ // `no-underscore-dangle` rightly rejects — and a fixture standing in for a connection it never
+ // opens is exactly the case for setting the state directly. `lastHealthyAt` is stamped too, since
+ // online-without-a-timestamp is a state the real socket never produces.
+ //
+ client.wsConnection.state.partialNext({
+ isHealthy: true,
+ lastHealthyAt: new Date(),
+ });
+ // The id lives on its own manager, and the request layer holds any watching request until one
+ // exists — so a fixture that omits this makes every mocked `watch()` wait forever.
+ client.connectionIdManager.resolveConnectionId('dummy_connection_id');
client.user = { ...user, mutes: [] } as UserResponse;
client['_user'] = { ...user } as UserResponse;
// `userID` is a getter in v10 (derives from `client.user?.id`), so it can't be assigned;
// setting `client.user` above is what populates it.
- client['userToken'] = token;
+ // `userToken` was never a field on `StreamChat` — only `userTokenOrProvider`, a parameter — so
+ // this assignment wrote a property nothing reads. The `tokenManager` mock below is what actually
+ // supplies the token.
client.wsPromise = Promise.resolve() as StreamChat['wsPromise'];
resolve();
});
diff --git a/yarn.lock b/yarn.lock
index ddfc7253d..67aba0577 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1861,7 +1861,7 @@ __metadata:
emoji-mart: "npm:^5.6.0"
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
- stream-chat: "npm:10.0.0-rc.11"
+ stream-chat: "npm:^10.0.0-rc.12"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
vite: "npm:^8.1.3"
@@ -1889,7 +1889,7 @@ __metadata:
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
sass: "npm:^1.100.0"
- stream-chat: "npm:10.0.0-rc.11"
+ stream-chat: "npm:^10.0.0-rc.12"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
vite: "npm:^8.1.3"
@@ -9551,7 +9551,7 @@ __metadata:
remark-parse: "npm:^11.0.0"
sass: "npm:^1.100.0"
semantic-release: "npm:^25.0.3"
- stream-chat: "npm:10.0.0-rc.11"
+ stream-chat: "npm:^10.0.0-rc.12"
typescript: "npm:^6.0.3"
typescript-eslint: "npm:^8.59.4"
unified: "npm:^11.0.5"
@@ -9569,7 +9569,7 @@ __metadata:
modern-normalize: ^3.0.1
react: ^19.0.0 || ^18.0.0 || ^17.0.0
react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0
- stream-chat: ^10.0.0-rc.10
+ stream-chat: ^10.0.0-rc.12
dependenciesMeta:
"@parcel/watcher":
built: true
@@ -9595,9 +9595,9 @@ __metadata:
languageName: unknown
linkType: soft
-"stream-chat@npm:10.0.0-rc.11":
- version: 10.0.0-rc.11
- resolution: "stream-chat@npm:10.0.0-rc.11"
+"stream-chat@npm:^10.0.0-rc.12":
+ version: 10.0.0-rc.12
+ resolution: "stream-chat@npm:10.0.0-rc.12"
dependencies:
"@stream-io/logger": "npm:^2.0.0"
"@stream-io/state-store": "npm:^1.1.6"
@@ -9608,7 +9608,7 @@ __metadata:
built: true
husky:
built: true
- checksum: 10c0/681c8dd92559843474ea26c12ea3e07b23dda254bfbc82295f1db326b210e2064d2911e8315bcce78b0606ef836c34ab890918aed0e831efb903dac8f25b1721
+ checksum: 10c0/908d8cdd440481cb84cf903eb0b7e840ca59615e95216a850765896433a7bcb6d438edfeccbe4ee601e4836dfe964e1c43ec7cbca9444cd980ebf9f4517b5d91
languageName: node
linkType: hard