Skip to content

Commit f790520

Browse files
authored
fix: bug bashing ChannelList + Channel (#2474, #2441, #2393) (#3227)
### 🎯 Goal Fixes three Tier‑1 issues from the bug‑bash triage. Each is an independent fix kept as its own self‑contained commit (with tests): - **Closes #2474** — a `message.new` for a not‑yet‑watched channel inserts an uninitialized channel into the list; rendering it called `channel.muteStatus()`, which throws `"hasn't been initialized yet"` and **crashed the whole app**. - **Closes #2441** — `ChannelList` issued a `queryChannel` (`channel.watch()`) for **every** `notification.message_new`, even when the result was discarded, exhausting the rate limit during bulk channel activity. - **Closes #2393** — once the shared client is disconnected, `channel.lastRead()` (called during render in `useCreateChannelStateContext`) throws `"You can't use a channel after client.disconnect() was called"`, **crashing the render** with no error boundary to catch it. > **#2599 — fixed via the `stream-chat` upstream fix + this PR's dependency bump.** An earlier `stopWatching`-on-removal attempt here was implemented and then reverted: it did **not** make removal durable. `channel.stopWatching()` does not evict the channel from `client.activeChannels`, so a reconnect (`recoverState` re-watches every active channel) or a queued/in-flight `message.new` re-added the removed channel — the exact reported symptom. The real fix evicts `activeChannels[cid]` on `notification.removed_from_channel` (mirroring the existing `channel.deleted` handling); it shipped in **`stream-chat@9.49.0`** (GetStream/stream-chat-js#1788). This PR bumps the `stream-chat` requirement to `^9.49.0` and adds a regression test — with the bump in place, `ChannelList` removal is durable and the test passes. ### 🛠 Implementation details **#2474 — `fix(ChannelListItem): guard muteStatus against an uninitialized/disconnected channel`** - `useIsChannelMuted` reads the mute status through a helper that calls `channel.muteStatus()` only when the channel is initialized **and not disconnected**, falling back to a not‑muted status otherwise. `muteStatus()` throws on an uninitialized channel (`#2474`) and also after the client disconnects — via `channel.getClient()` — even though the channel stays initialized (the #2393 failure class on the channel‑list path). - The mute‑update subscription now depends on `[channel, client]` and cleans up via the `unsubscribe` handle returned by `client.on()`. It previously depended on `[muted]`, so it re‑subscribed on its own output and could capture a stale channel. **#2441 — `fix(ChannelList): do not query unfiltered channels on notification.message_new`** - Moved the `allowNewMessagesFromUnfilteredChannels` guard **before** the `getChannel()` call in `handleNotificationMessageNew`, so the flag actually suppresses the per‑event `watch()` instead of querying first and throwing the result away. Behaviour with the flag enabled is unchanged. **#2393 — `fix(Channel): guard channel methods against a disconnected client`** - Guarded `channel.lastRead()` in `useCreateChannelStateContext` with `!channel.disconnected` so a disconnect while the channel is mounted does not crash the render. - `loadMore` now early‑returns when `channel.disconnected`, mirroring the existing `markRead` guard, to avoid a doomed pagination query. - Note: the shared‑client race cannot be fully eliminated (the existing `try/catch` backstops on the async paths remain); this removes the uncaught **render‑time** crash, which is the part with no safety net. **#2599 — `fix(deps): require stream-chat ^9.49.0 to evict removed channels`** - Bumped `stream-chat` to `^9.49.0` in `peerDependencies` and `devDependencies`. No `ChannelList` runtime change is required: the existing `handleNotificationRemovedFromChannel` removes the channel from the list, and `stream-chat@9.49.0` now evicts it from `client.activeChannels`, so `recoverState()` won't re‑watch it on reconnect. The two compose into durable removal. **Tests** — added test‑first (each test was confirmed to fail before the fix): - `useIsChannelMuted` does not throw on an uninitialized channel, nor on an initialized‑but‑disconnected channel. - `notification.message_new` does not call `watch()` when `allowNewMessagesFromUnfilteredChannels={false}`. - rendering does not crash when the client disconnects while the channel is mounted. - `loadMore` does not query when the client is disconnected. - **#2599:** `notification.removed_from_channel` evicts the channel from `client.activeChannels` (so reconnect cannot re‑watch it). Verified red→green — red on `stream-chat@9.47.0`, green after the `^9.49.0` bump. Verification: `yarn test` (full suite — 2589 passing, incl. ChannelList, ChannelListItem, Channel, MessageList, Thread), `yarn types`, and ESLint (`--max-warnings 0`) all clean. #2474, #2441 and #2393 were additionally confirmed in a real browser against the example app; the #2599 regression test passes against the bumped `stream-chat@9.49.0`. ### 🎨 UI Changes None — these are crash / correctness / rate‑limit fixes in `ChannelList` and `Channel` event handling, with no visual changes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed message-new handling when unfiltered channels aren’t allowed so unlisted/unqueried channels aren’t watched or added to the list. * Made mute status lookups safe when a channel isn’t initialized or is disconnected. * Prevented pagination and last-read handling from running while the client/channel is disconnected to avoid crashes. * **Tests** * Added coverage for message-new filtering with unfiltered channels disabled. * Expanded mute hook tests for uninitialized and disconnected cases. * Added disconnected-client scenarios for rendering and pagination. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a768e30 commit f790520

11 files changed

Lines changed: 259 additions & 23 deletions

File tree

examples/tutorial/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"emoji-mart": "^5.6.0",
1717
"react": "^19.2.6",
1818
"react-dom": "^19.2.6",
19-
"stream-chat": "^9.47.0",
19+
"stream-chat": "^9.49.0",
2020
"stream-chat-react": "workspace:^"
2121
},
2222
"devDependencies": {

examples/vite/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"modern-normalize": "^3.0.1",
1717
"react": "^19.2.6",
1818
"react-dom": "^19.2.6",
19-
"stream-chat": "^9.47.0",
19+
"stream-chat": "^9.49.0",
2020
"stream-chat-react": "workspace:^"
2121
},
2222
"devDependencies": {

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@
111111
"modern-normalize": "^3.0.1",
112112
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
113113
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
114-
"stream-chat": "^9.47.0"
114+
"stream-chat": "^9.49.0"
115115
},
116116
"peerDependenciesMeta": {
117117
"@breezystack/lamejs": {
@@ -181,7 +181,7 @@
181181
"react-dom": "^19.2.6",
182182
"sass": "^1.100.0",
183183
"semantic-release": "^25.0.3",
184-
"stream-chat": "^9.47.0",
184+
"stream-chat": "^9.49.0",
185185
"typescript": "^6.0.3",
186186
"typescript-eslint": "^8.59.4",
187187
"vite": "^8.0.14",

src/components/Channel/Channel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ const ChannelInner = (
602602

603603
const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => {
604604
if (
605+
channel.disconnected ||
605606
!online.current ||
606607
!window.navigator.onLine ||
607608
!channel.state.messagePagination.hasPrev

src/components/Channel/__tests__/Channel.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ChatProvider, useChatContext } from '../../../context/ChatContext';
2424
import { useComponentContext } from '../../../context/ComponentContext';
2525
import {
2626
dispatchChannelTruncatedEvent,
27+
dispatchConnectionChangedEvent,
2728
generateChannel,
2829
generateFileAttachment,
2930
generateMember,
@@ -779,6 +780,47 @@ describe('Channel', () => {
779780
await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1));
780781
});
781782

783+
describe('disconnected client (#2393)', () => {
784+
it('does not crash rendering when the client disconnects while the channel is mounted', async () => {
785+
let ctx: ChannelStateContextValue | undefined;
786+
await renderComponent({ channel, chatClient }, (c) => {
787+
ctx = c;
788+
});
789+
790+
// the channel is initialized; the shared client then disconnects
791+
channel.disconnected = true;
792+
793+
// a re-render that reads channel state must not throw
794+
// (channel.lastRead() throws once the client is disconnected)
795+
await act(async () => {
796+
dispatchConnectionChangedEvent(chatClient, false);
797+
await Promise.resolve();
798+
});
799+
800+
expect(ctx).toBeDefined();
801+
expect(ctx?.error).toBeNull();
802+
});
803+
804+
it('does not paginate (query) when the client is disconnected', async () => {
805+
let loadMore: ChannelActionContextValue['loadMore'] | undefined;
806+
await renderComponent(
807+
{ channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient },
808+
(c) => {
809+
loadMore = c.loadMore;
810+
},
811+
);
812+
813+
const querySpy = vi.spyOn(channel, 'query');
814+
channel.disconnected = true;
815+
816+
await act(async () => {
817+
await loadMore?.();
818+
});
819+
820+
expect(querySpy).not.toHaveBeenCalled();
821+
});
822+
});
823+
782824
describe('Children that consume the contexts set in Channel', () => {
783825
it('should be able to open threads', async () => {
784826
const threadMessage = messages[0];

src/components/Channel/hooks/useCreateChannelStateContext.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ export const useCreateChannelStateContext = (
4444
} = value;
4545

4646
const channelId = channel.cid;
47-
const lastRead = channel.initialized && channel.lastRead()?.getTime();
47+
// `channel.lastRead()` reaches `channel.getClient()`, which throws once the
48+
// client has been disconnected. Guard against it so a disconnect while the
49+
// channel is mounted does not crash the render (#2393).
50+
const lastRead =
51+
channel.initialized && !channel.disconnected && channel.lastRead()?.getTime();
4852
const membersLength = Object.keys(members || []).length;
4953
const notificationsLength = notifications.length;
5054
const readUsers = Object.values(read);

src/components/ChannelList/__tests__/ChannelList.test.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,56 @@ describe('ChannelList', () => {
12541254
const results = await axe(container);
12551255
expect(results).toHaveNoViolations();
12561256
});
1257+
1258+
it('does not query (watch) the channel when allowNewMessagesFromUnfilteredChannels is false (#2441)', async () => {
1259+
useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
1260+
1261+
const { getByRole, queryByTestId } = await render(
1262+
<Chat client={chatClient}>
1263+
<WithComponents
1264+
overrides={{
1265+
ChannelListItemUI: ChannelPreviewComponent,
1266+
ChannelListUI: ChannelListComponent,
1267+
}}
1268+
>
1269+
<ChannelList
1270+
allowNewMessagesFromUnfilteredChannels={false}
1271+
filters={{}}
1272+
options={{
1273+
limit: 25,
1274+
message_limit: 25,
1275+
presence: true,
1276+
state: true,
1277+
watch: true,
1278+
}}
1279+
/>
1280+
</WithComponents>
1281+
</Chat>,
1282+
);
1283+
1284+
await waitFor(() => {
1285+
expect(getByRole('list')).toBeInTheDocument();
1286+
});
1287+
1288+
// getChannel() resolves `client.channel(type, id)` to this cached
1289+
// instance, so spying on its watch() reveals whether a queryChannel was
1290+
// issued for an unfiltered channel.
1291+
const unlistedChannel = chatClient.channel(
1292+
testChannel3.channel.type,
1293+
testChannel3.channel.id,
1294+
);
1295+
const watchSpy = vi
1296+
.spyOn(unlistedChannel, 'watch')
1297+
.mockResolvedValue(fromPartial({}));
1298+
1299+
await act(async () => {
1300+
dispatchNotificationMessageNewEvent(chatClient, testChannel3.channel);
1301+
await Promise.resolve();
1302+
});
1303+
1304+
expect(watchSpy).not.toHaveBeenCalled();
1305+
expect(queryByTestId(testChannel3.channel.id)).not.toBeInTheDocument();
1306+
});
12571307
});
12581308

12591309
describe('notification.added_to_channel', () => {
@@ -1419,6 +1469,47 @@ describe('ChannelList', () => {
14191469
const results = await axe(container);
14201470
expect(results).toHaveNoViolations();
14211471
});
1472+
1473+
// Regression: #2599. Removing the current user from a channel must evict it
1474+
// from `client.activeChannels`. Otherwise the channel lingers there and, on
1475+
// reconnect, `recoverState()` re-watches it (`recoverStateOnReconnect` defaults
1476+
// to `true`, so `usePaginatedChannels` does not re-query and relies on core
1477+
// recovery) - channel events then resume and the list resurrects it. The
1478+
// eviction itself lives in stream-chat core (`StreamChat._handleClientEvent`);
1479+
// this test guards that the behaviour ChannelList depends on stays in place.
1480+
it('evicts the removed channel from client.activeChannels so reconnect cannot re-watch it (#2599)', async () => {
1481+
const { getByRole, getByTestId } = await render(
1482+
<Chat client={chatClient}>
1483+
<WithComponents
1484+
overrides={{
1485+
ChannelListItemUI: ChannelPreviewComponent,
1486+
ChannelListUI: ChannelListComponent,
1487+
}}
1488+
>
1489+
<ChannelList {...channelListProps} />
1490+
</WithComponents>
1491+
</Chat>,
1492+
);
1493+
await waitFor(() => {
1494+
expect(getByRole('list')).toBeInTheDocument();
1495+
});
1496+
1497+
const { cid } = testChannel3.channel;
1498+
const removedNode = getByTestId(testChannel3.channel.id);
1499+
// precondition: the loaded channel is tracked by the client
1500+
expect(chatClient.activeChannels[cid]).toBeDefined();
1501+
1502+
act(() =>
1503+
dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel),
1504+
);
1505+
1506+
await waitFor(() => {
1507+
expect(removedNode).not.toBeInTheDocument();
1508+
});
1509+
// the channel must no longer be tracked, otherwise `recoverState()` would
1510+
// re-watch it on the next reconnect and bring it back into the list
1511+
expect(chatClient.activeChannels[cid]).toBeUndefined();
1512+
});
14221513
});
14231514

14241515
describe('channel.updated', () => {

src/components/ChannelList/hooks/useChannelListShape.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ export const useChannelListShapeDefaults = () => {
163163
return;
164164
}
165165

166+
// Bail out before querying the channel: if new messages from unfiltered
167+
// channels are not allowed, this handler would discard the result anyway.
168+
// Querying first issued a `channel.watch()` (queryChannel) for every
169+
// `notification.message_new`, which could exhaust the rate limit when many
170+
// such events arrive at once (#2441).
171+
if (!allowNewMessagesFromUnfilteredChannels) {
172+
return;
173+
}
174+
166175
const channel = await getChannel({
167176
client,
168177
id: event.channel.id,
@@ -174,10 +183,6 @@ export const useChannelListShapeDefaults = () => {
174183
return;
175184
}
176185

177-
if (!allowNewMessagesFromUnfilteredChannels) {
178-
return;
179-
}
180-
181186
setChannels((channels) =>
182187
moveChannelUpwards({
183188
channels,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from 'react';
2+
import { renderHook } from '@testing-library/react';
3+
import { fromPartial } from '@total-typescript/shoehorn';
4+
5+
import { ChatContext } from '../../../../context/ChatContext';
6+
import type { ChatContextValue } from '../../../../context/ChatContext';
7+
import {
8+
generateChannel,
9+
generateUser,
10+
getOrCreateChannelApi,
11+
getTestClientWithUser,
12+
useMockedApis,
13+
} from '../../../../mock-builders';
14+
import { useIsChannelMuted } from '../useIsChannelMuted';
15+
16+
const clientUser = generateUser({ id: 'current-user' });
17+
18+
const createWrapper = (client) =>
19+
function Wrapper({ children }) {
20+
return (
21+
<ChatContext.Provider value={fromPartial<ChatContextValue>({ client })}>
22+
{children}
23+
</ChatContext.Provider>
24+
);
25+
};
26+
27+
describe('useIsChannelMuted', () => {
28+
it('does not throw when the channel has not been initialized (watched) yet', async () => {
29+
const client = await getTestClientWithUser(clientUser);
30+
// A channel that was never watched/queried is not initialized; calling
31+
// channel.muteStatus() on it throws `_checkInitialized` and crashes the app
32+
// when such a channel is rendered in the ChannelList (issue #2474).
33+
const channel = client.channel('messaging', 'never-watched-channel');
34+
35+
expect(channel.initialized).toBe(false);
36+
37+
const { result } = renderHook(() => useIsChannelMuted(channel), {
38+
wrapper: createWrapper(client),
39+
});
40+
41+
expect(result.current.muted).toBe(false);
42+
});
43+
44+
it('returns the channel mute status for an initialized channel', async () => {
45+
const client = await getTestClientWithUser(clientUser);
46+
const mockedChannel = generateChannel();
47+
useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]);
48+
const channel = client.channel('messaging', mockedChannel.channel.id);
49+
await channel.watch();
50+
51+
const { result } = renderHook(() => useIsChannelMuted(channel), {
52+
wrapper: createWrapper(client),
53+
});
54+
55+
expect(result.current.muted).toBe(false);
56+
});
57+
58+
it('does not throw when an initialized channel has been disconnected', async () => {
59+
const client = await getTestClientWithUser(clientUser);
60+
const mockedChannel = generateChannel();
61+
useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]);
62+
const channel = client.channel('messaging', mockedChannel.channel.id);
63+
await channel.watch();
64+
65+
expect(channel.initialized).toBe(true);
66+
// Once the client is disconnected (e.g. client.disconnectUser()), the channel
67+
// stays initialized but channel.muteStatus() -> channel.getClient() throws
68+
// "You can't use a channel after client.disconnect() was called", crashing the
69+
// ChannelListItem render unless we guard against it (#2393 failure class).
70+
channel.disconnected = true;
71+
72+
const { result } = renderHook(() => useIsChannelMuted(channel), {
73+
wrapper: createWrapper(client),
74+
});
75+
76+
expect(result.current.muted).toBe(false);
77+
});
78+
});

src/components/ChannelListItem/hooks/useIsChannelMuted.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,33 @@ import { useChatContext } from '../../../context/ChatContext';
44

55
import type { Channel } from 'stream-chat';
66

7+
/**
8+
* `channel.muteStatus()` throws in two cases:
9+
* - the channel has not been initialized yet (i.e. `.watch()`/`.query()` has not
10+
* resolved) - the ChannelList can briefly hold such channels, e.g. a
11+
* `message.new` for a channel that has not been watched yet (#2474);
12+
* - the channel is disconnected (e.g. after `client.disconnectUser()`), in which
13+
* case `channel.getClient()` throws even though the channel stays initialized
14+
* (#2393 failure class).
15+
* Guard against both to avoid crashing the whole app.
16+
*/
17+
const getMuteStatus = (channel: Channel) =>
18+
channel.initialized && !channel.disconnected
19+
? channel.muteStatus()
20+
: { createdAt: null, expiresAt: null, muted: false };
21+
722
export const useIsChannelMuted = (channel: Channel) => {
823
const { client } = useChatContext('useIsChannelMuted');
924

10-
const [muted, setMuted] = useState(channel.muteStatus());
25+
const [muted, setMuted] = useState(() => getMuteStatus(channel));
1126

1227
useEffect(() => {
13-
const handleEvent = () => setMuted(channel.muteStatus());
28+
const handleEvent = () => {
29+
setMuted(getMuteStatus(channel));
30+
};
1431

15-
client.on('notification.channel_mutes_updated', handleEvent);
16-
return () => client.off('notification.channel_mutes_updated', handleEvent);
17-
// eslint-disable-next-line react-hooks/exhaustive-deps
18-
}, [muted]);
32+
return client.on('notification.channel_mutes_updated', handleEvent).unsubscribe;
33+
}, [channel, client]);
1934

2035
return muted;
2136
};

0 commit comments

Comments
 (0)