diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json
index bb9a3b014..7a799514d 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": "^9.47.0",
+ "stream-chat": "^9.49.0",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
diff --git a/examples/vite/package.json b/examples/vite/package.json
index 0e734adf1..37ea3cf56 100644
--- a/examples/vite/package.json
+++ b/examples/vite/package.json
@@ -16,7 +16,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
- "stream-chat": "^9.47.0",
+ "stream-chat": "^9.49.0",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
diff --git a/package.json b/package.json
index 22d1bfbaa..ce2e0b998 100644
--- a/package.json
+++ b/package.json
@@ -111,7 +111,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": "^9.47.0"
+ "stream-chat": "^9.49.0"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
@@ -181,7 +181,7 @@
"react-dom": "^19.2.6",
"sass": "^1.100.0",
"semantic-release": "^25.0.3",
- "stream-chat": "^9.47.0",
+ "stream-chat": "^9.49.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index c2a1cfde6..e070a39ea 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -602,6 +602,7 @@ const ChannelInner = (
const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => {
if (
+ channel.disconnected ||
!online.current ||
!window.navigator.onLine ||
!channel.state.messagePagination.hasPrev
diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx
index 4bebd0400..04f8be01e 100644
--- a/src/components/Channel/__tests__/Channel.test.tsx
+++ b/src/components/Channel/__tests__/Channel.test.tsx
@@ -24,6 +24,7 @@ import { ChatProvider, useChatContext } from '../../../context/ChatContext';
import { useComponentContext } from '../../../context/ComponentContext';
import {
dispatchChannelTruncatedEvent,
+ dispatchConnectionChangedEvent,
generateChannel,
generateFileAttachment,
generateMember,
@@ -779,6 +780,47 @@ describe('Channel', () => {
await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1));
});
+ describe('disconnected client (#2393)', () => {
+ it('does not crash rendering when the client disconnects while the channel is mounted', async () => {
+ let ctx: ChannelStateContextValue | undefined;
+ await renderComponent({ channel, chatClient }, (c) => {
+ ctx = c;
+ });
+
+ // the channel is initialized; the shared client then disconnects
+ channel.disconnected = true;
+
+ // a re-render that reads channel state must not throw
+ // (channel.lastRead() throws once the client is disconnected)
+ await act(async () => {
+ dispatchConnectionChangedEvent(chatClient, false);
+ await Promise.resolve();
+ });
+
+ expect(ctx).toBeDefined();
+ expect(ctx?.error).toBeNull();
+ });
+
+ it('does not paginate (query) when the client is disconnected', async () => {
+ let loadMore: ChannelActionContextValue['loadMore'] | undefined;
+ await renderComponent(
+ { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient },
+ (c) => {
+ loadMore = c.loadMore;
+ },
+ );
+
+ const querySpy = vi.spyOn(channel, 'query');
+ channel.disconnected = true;
+
+ await act(async () => {
+ await loadMore?.();
+ });
+
+ expect(querySpy).not.toHaveBeenCalled();
+ });
+ });
+
describe('Children that consume the contexts set in Channel', () => {
it('should be able to open threads', async () => {
const threadMessage = messages[0];
diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts
index 0d7b8687e..07232a14c 100644
--- a/src/components/Channel/hooks/useCreateChannelStateContext.ts
+++ b/src/components/Channel/hooks/useCreateChannelStateContext.ts
@@ -44,7 +44,11 @@ export const useCreateChannelStateContext = (
} = value;
const channelId = channel.cid;
- const lastRead = channel.initialized && channel.lastRead()?.getTime();
+ // `channel.lastRead()` reaches `channel.getClient()`, which throws once the
+ // client has been disconnected. Guard against it so a disconnect while the
+ // channel is mounted does not crash the render (#2393).
+ const lastRead =
+ channel.initialized && !channel.disconnected && channel.lastRead()?.getTime();
const membersLength = Object.keys(members || []).length;
const notificationsLength = notifications.length;
const readUsers = Object.values(read);
diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx
index 1ed07737d..d1e5ce12c 100644
--- a/src/components/ChannelList/__tests__/ChannelList.test.tsx
+++ b/src/components/ChannelList/__tests__/ChannelList.test.tsx
@@ -1254,6 +1254,56 @@ describe('ChannelList', () => {
const results = await axe(container);
expect(results).toHaveNoViolations();
});
+
+ it('does not query (watch) the channel when allowNewMessagesFromUnfilteredChannels is false (#2441)', async () => {
+ useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
+
+ const { getByRole, queryByTestId } = await render(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('list')).toBeInTheDocument();
+ });
+
+ // getChannel() resolves `client.channel(type, id)` to this cached
+ // instance, so spying on its watch() reveals whether a queryChannel was
+ // issued for an unfiltered channel.
+ const unlistedChannel = chatClient.channel(
+ testChannel3.channel.type,
+ testChannel3.channel.id,
+ );
+ const watchSpy = vi
+ .spyOn(unlistedChannel, 'watch')
+ .mockResolvedValue(fromPartial({}));
+
+ await act(async () => {
+ dispatchNotificationMessageNewEvent(chatClient, testChannel3.channel);
+ await Promise.resolve();
+ });
+
+ expect(watchSpy).not.toHaveBeenCalled();
+ expect(queryByTestId(testChannel3.channel.id)).not.toBeInTheDocument();
+ });
});
describe('notification.added_to_channel', () => {
@@ -1419,6 +1469,47 @@ describe('ChannelList', () => {
const results = await axe(container);
expect(results).toHaveNoViolations();
});
+
+ // Regression: #2599. Removing the current user from a channel must evict it
+ // from `client.activeChannels`. Otherwise the channel lingers there and, on
+ // reconnect, `recoverState()` re-watches it (`recoverStateOnReconnect` defaults
+ // to `true`, so `usePaginatedChannels` does not re-query and relies on core
+ // recovery) - channel events then resume and the list resurrects it. The
+ // eviction itself lives in stream-chat core (`StreamChat._handleClientEvent`);
+ // this test guards that the behaviour ChannelList depends on stays in place.
+ it('evicts the removed channel from client.activeChannels so reconnect cannot re-watch it (#2599)', async () => {
+ const { getByRole, getByTestId } = await render(
+
+
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(getByRole('list')).toBeInTheDocument();
+ });
+
+ const { cid } = testChannel3.channel;
+ const removedNode = getByTestId(testChannel3.channel.id);
+ // precondition: the loaded channel is tracked by the client
+ expect(chatClient.activeChannels[cid]).toBeDefined();
+
+ act(() =>
+ dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel),
+ );
+
+ await waitFor(() => {
+ expect(removedNode).not.toBeInTheDocument();
+ });
+ // the channel must no longer be tracked, otherwise `recoverState()` would
+ // re-watch it on the next reconnect and bring it back into the list
+ expect(chatClient.activeChannels[cid]).toBeUndefined();
+ });
});
describe('channel.updated', () => {
diff --git a/src/components/ChannelList/hooks/useChannelListShape.ts b/src/components/ChannelList/hooks/useChannelListShape.ts
index d97b2cdc6..fa5feb7f9 100644
--- a/src/components/ChannelList/hooks/useChannelListShape.ts
+++ b/src/components/ChannelList/hooks/useChannelListShape.ts
@@ -163,6 +163,15 @@ export const useChannelListShapeDefaults = () => {
return;
}
+ // Bail out before querying the channel: if new messages from unfiltered
+ // channels are not allowed, this handler would discard the result anyway.
+ // Querying first issued a `channel.watch()` (queryChannel) for every
+ // `notification.message_new`, which could exhaust the rate limit when many
+ // such events arrive at once (#2441).
+ if (!allowNewMessagesFromUnfilteredChannels) {
+ return;
+ }
+
const channel = await getChannel({
client,
id: event.channel.id,
@@ -174,10 +183,6 @@ export const useChannelListShapeDefaults = () => {
return;
}
- if (!allowNewMessagesFromUnfilteredChannels) {
- return;
- }
-
setChannels((channels) =>
moveChannelUpwards({
channels,
diff --git a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx
new file mode 100644
index 000000000..50a0c08f5
--- /dev/null
+++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+
+import { ChatContext } from '../../../../context/ChatContext';
+import type { ChatContextValue } from '../../../../context/ChatContext';
+import {
+ generateChannel,
+ generateUser,
+ getOrCreateChannelApi,
+ getTestClientWithUser,
+ useMockedApis,
+} from '../../../../mock-builders';
+import { useIsChannelMuted } from '../useIsChannelMuted';
+
+const clientUser = generateUser({ id: 'current-user' });
+
+const createWrapper = (client) =>
+ function Wrapper({ children }) {
+ return (
+ ({ client })}>
+ {children}
+
+ );
+ };
+
+describe('useIsChannelMuted', () => {
+ it('does not throw when the channel has not been initialized (watched) yet', async () => {
+ const client = await getTestClientWithUser(clientUser);
+ // A channel that was never watched/queried is not initialized; calling
+ // channel.muteStatus() on it throws `_checkInitialized` and crashes the app
+ // when such a channel is rendered in the ChannelList (issue #2474).
+ const channel = client.channel('messaging', 'never-watched-channel');
+
+ expect(channel.initialized).toBe(false);
+
+ const { result } = renderHook(() => useIsChannelMuted(channel), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(result.current.muted).toBe(false);
+ });
+
+ it('returns the channel mute status for an initialized channel', async () => {
+ const client = await getTestClientWithUser(clientUser);
+ const mockedChannel = generateChannel();
+ useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = client.channel('messaging', mockedChannel.channel.id);
+ await channel.watch();
+
+ const { result } = renderHook(() => useIsChannelMuted(channel), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(result.current.muted).toBe(false);
+ });
+
+ it('does not throw when an initialized channel has been disconnected', async () => {
+ const client = await getTestClientWithUser(clientUser);
+ const mockedChannel = generateChannel();
+ useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = client.channel('messaging', mockedChannel.channel.id);
+ await channel.watch();
+
+ expect(channel.initialized).toBe(true);
+ // Once the client is disconnected (e.g. client.disconnectUser()), the channel
+ // stays initialized but channel.muteStatus() -> channel.getClient() throws
+ // "You can't use a channel after client.disconnect() was called", crashing the
+ // ChannelListItem render unless we guard against it (#2393 failure class).
+ channel.disconnected = true;
+
+ const { result } = renderHook(() => useIsChannelMuted(channel), {
+ wrapper: createWrapper(client),
+ });
+
+ expect(result.current.muted).toBe(false);
+ });
+});
diff --git a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts
index be2a888e0..88d2c15ee 100644
--- a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts
+++ b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts
@@ -4,18 +4,33 @@ import { useChatContext } from '../../../context/ChatContext';
import type { Channel } from 'stream-chat';
+/**
+ * `channel.muteStatus()` throws in two cases:
+ * - the channel has not been initialized yet (i.e. `.watch()`/`.query()` has not
+ * resolved) - the ChannelList can briefly hold such channels, e.g. a
+ * `message.new` for a channel that has not been watched yet (#2474);
+ * - the channel is disconnected (e.g. after `client.disconnectUser()`), in which
+ * case `channel.getClient()` throws even though the channel stays initialized
+ * (#2393 failure class).
+ * Guard against both to avoid crashing the whole app.
+ */
+const getMuteStatus = (channel: Channel) =>
+ channel.initialized && !channel.disconnected
+ ? channel.muteStatus()
+ : { createdAt: null, expiresAt: null, muted: false };
+
export const useIsChannelMuted = (channel: Channel) => {
const { client } = useChatContext('useIsChannelMuted');
- const [muted, setMuted] = useState(channel.muteStatus());
+ const [muted, setMuted] = useState(() => getMuteStatus(channel));
useEffect(() => {
- const handleEvent = () => setMuted(channel.muteStatus());
+ const handleEvent = () => {
+ setMuted(getMuteStatus(channel));
+ };
- client.on('notification.channel_mutes_updated', handleEvent);
- return () => client.off('notification.channel_mutes_updated', handleEvent);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [muted]);
+ return client.on('notification.channel_mutes_updated', handleEvent).unsubscribe;
+ }, [channel, client]);
return muted;
};
diff --git a/yarn.lock b/yarn.lock
index fe3924585..1848a9f82 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2079,7 +2079,7 @@ __metadata:
emoji-mart: "npm:^5.6.0"
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
- stream-chat: "npm:^9.47.0"
+ stream-chat: "npm:^9.49.0"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
vite: "npm:^8.0.14"
@@ -2105,7 +2105,7 @@ __metadata:
react: "npm:^19.2.6"
react-dom: "npm:^19.2.6"
sass: "npm:^1.100.0"
- stream-chat: "npm:^9.47.0"
+ stream-chat: "npm:^9.49.0"
stream-chat-react: "workspace:^"
typescript: "npm:^6.0.3"
vite: "npm:^8.0.14"
@@ -10104,7 +10104,7 @@ __metadata:
remark-gfm: "npm:^4.0.1"
sass: "npm:^1.100.0"
semantic-release: "npm:^25.0.3"
- stream-chat: "npm:^9.47.0"
+ stream-chat: "npm:^9.49.0"
typescript: "npm:^6.0.3"
typescript-eslint: "npm:^8.59.4"
unist-builder: "npm:^4.0.0"
@@ -10121,7 +10121,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: ^9.47.0
+ stream-chat: ^9.49.0
dependenciesMeta:
"@parcel/watcher":
built: true
@@ -10147,9 +10147,9 @@ __metadata:
languageName: unknown
linkType: soft
-"stream-chat@npm:^9.47.0":
- version: 9.47.0
- resolution: "stream-chat@npm:9.47.0"
+"stream-chat@npm:^9.49.0":
+ version: 9.49.0
+ resolution: "stream-chat@npm:9.49.0"
dependencies:
"@types/jsonwebtoken": "npm:^9.0.8"
"@types/ws": "npm:^8.18.1"
@@ -10165,7 +10165,7 @@ __metadata:
built: true
husky:
built: true
- checksum: 10c0/6f1a84f31047d0ccaf764ee106a276d7361b1582083c762cecd0e427d37ff6b9bfc69decaa856be0ab81d42f53beea12a231bc207d73dcbf28126abc9e28d9f3
+ checksum: 10c0/2fceb2434d69523b09b9c6419ab46f2c181aa2ef0b269ed388ce53254f8a4c618eb14ead46d2ae5245c2070c33ab35d33ac1c3671610774503da8a160d71e985
languageName: node
linkType: hard