From 47ae68893f92bbd7992e5ce2cb07588d1ac21f5a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 29 Jun 2026 14:42:09 +0200 Subject: [PATCH 01/10] fix(ChannelListItem): guard muteStatus against uninitialized channels useIsChannelMuted called channel.muteStatus() during render, which throws "hasn't been initialized yet" when the channel has not been watched. The ChannelList can briefly hold such a channel (e.g. a message.new for a channel that has not been watched yet), and the throw crashed the whole app. Guard the call behind channel.initialized and fall back to a not-muted status. Closes #2474 --- .../__tests__/useIsChannelMuted.test.tsx | 57 +++++++++++++++++++ .../hooks/useIsChannelMuted.ts | 15 ++++- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx 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 0000000000..da668d783f --- /dev/null +++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx @@ -0,0 +1,57 @@ +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); + }); +}); diff --git a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts index be2a888e00..7423e1ec8a 100644 --- a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts +++ b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts @@ -4,13 +4,24 @@ import { useChatContext } from '../../../context/ChatContext'; import type { Channel } from 'stream-chat'; +/** + * `channel.muteStatus()` throws when 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 - so guard against it to avoid crashing the whole app (#2474). + */ +const getMuteStatus = (channel: Channel) => + channel.initialized + ? 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); From 9dc7444277cc5c275d8d040525ca9a4441533bbd Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 29 Jun 2026 14:44:37 +0200 Subject: [PATCH 02/10] fix(ChannelList): do not query unfiltered channels on notification.message_new handleNotificationMessageNew called getChannel() (channel.watch()) before checking allowNewMessagesFromUnfilteredChannels, so a queryChannel was issued for every notification.message_new even when the result was discarded. When many such events arrive at once (e.g. a bulk campaign) this exhausted the rate limit. Move the guard ahead of the query so the flag actually suppresses the watch(). Closes #2441 --- .../__tests__/ChannelList.test.tsx | 50 +++++++++++++++++++ .../ChannelList/hooks/useChannelListShape.ts | 13 +++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx index 1ed07737d2..95c42f2057 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', () => { diff --git a/src/components/ChannelList/hooks/useChannelListShape.ts b/src/components/ChannelList/hooks/useChannelListShape.ts index d97b2cdc6d..fa5feb7f94 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, From 3bef4e1fd0bb05956bf8f85fa8d59d3e46a6ac73 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 29 Jun 2026 14:45:08 +0200 Subject: [PATCH 03/10] fix(ChannelList): stop watching channels removed from the list Removing a channel from the list did not stop watching it, so the client kept receiving its events and a later message.new / notification.message_new re-added it to the list until a hard refresh. notification.removed_from_channel now calls channel.stopWatching(), and a new member.removed handler removes the channel and stops watching it when the current user is the removed member. Closes #2599 --- .../__tests__/ChannelList.test.tsx | 114 ++++++++++++++++++ .../ChannelList/hooks/useChannelListShape.ts | 46 ++++++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx index 95c42f2057..c7999d8082 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -1469,6 +1469,120 @@ describe('ChannelList', () => { const results = await axe(container); expect(results).toHaveNoViolations(); }); + + it('stops watching the channel it removes so later events cannot re-add it (#2599)', async () => { + const { getByRole } = await render( + + + + + , + ); + await waitFor(() => { + expect(getByRole('list')).toBeInTheDocument(); + }); + + const removedChannel = chatClient.activeChannels[testChannel3.channel.cid]; + expect(removedChannel).toBeDefined(); + const stopWatchingSpy = vi + .spyOn(removedChannel, 'stopWatching') + .mockResolvedValue(fromPartial({})); + + act(() => + dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel), + ); + + expect(stopWatchingSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('member.removed', () => { + const channelListProps = { + filters: {}, + options: { limit: 25, message_limit: 25 }, + }; + + const dispatchMemberRemoved = (channel: ChannelAPIResponse, userId?: string) => + chatClient.dispatchEvent( + fromPartial({ + channel_id: channel.channel.id, + channel_type: channel.channel.type, + cid: channel.channel.cid, + member: { user: { id: userId ?? chatClient.userID } }, + type: 'member.removed', + }), + ); + + beforeEach(() => { + useMockedApis(chatClient, [ + queryChannelsApi([testChannel1, testChannel2, testChannel3]), + ]); + }); + + it('removes the channel and stops watching it when the current user is removed (#2599)', async () => { + const { getByRole, getByTestId, queryByTestId } = await render( + + + + + , + ); + await waitFor(() => { + expect(getByRole('list')).toBeInTheDocument(); + }); + expect(getByTestId(testChannel3.channel.id)).toBeInTheDocument(); + + const removedChannel = chatClient.activeChannels[testChannel3.channel.cid]; + const stopWatchingSpy = vi + .spyOn(removedChannel, 'stopWatching') + .mockResolvedValue(fromPartial({})); + + act(() => dispatchMemberRemoved(testChannel3)); + + await waitFor(() => { + expect(queryByTestId(testChannel3.channel.id)).not.toBeInTheDocument(); + }); + expect(stopWatchingSpy).toHaveBeenCalledTimes(1); + }); + + it('ignores member.removed for a different user', async () => { + const { getByRole, getByTestId } = await render( + + + + + , + ); + await waitFor(() => { + expect(getByRole('list')).toBeInTheDocument(); + }); + + const otherChannel = chatClient.activeChannels[testChannel3.channel.cid]; + const stopWatchingSpy = vi + .spyOn(otherChannel, 'stopWatching') + .mockResolvedValue(fromPartial({})); + + act(() => dispatchMemberRemoved(testChannel3, 'someone-else')); + + // channel must remain and must not be unwatched + expect(getByTestId(testChannel3.channel.id)).toBeInTheDocument(); + expect(stopWatchingSpy).not.toHaveBeenCalled(); + }); }); describe('channel.updated', () => { diff --git a/src/components/ChannelList/hooks/useChannelListShape.ts b/src/components/ChannelList/hooks/useChannelListShape.ts index fa5feb7f94..b017dceaec 100644 --- a/src/components/ChannelList/hooks/useChannelListShape.ts +++ b/src/components/ChannelList/hooks/useChannelListShape.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import type { Channel, Event } from 'stream-chat'; +import type { Channel, Event, StreamChat } from 'stream-chat'; import type { Dispatch, SetStateAction } from 'react'; import { @@ -57,6 +57,8 @@ type HandleMemberUpdatedParameters = BaseParameters & { lockChannelOrder: boolean; } & Required>; +type HandleMemberRemovedParameters = BaseParameters; + type HandleChannelDeletedParameters = BaseParameters & RepeatedParameters; type HandleChannelHiddenParameters = BaseParameters & RepeatedParameters; @@ -87,6 +89,24 @@ const shared = ({ }); }; +/** + * Stops watching a channel that has just been removed from the list. Without + * this the client keeps watching the channel, so a later `message.new` / + * `notification.message_new` re-adds it to the list until a hard refresh (#2599). + */ +const stopWatchingRemovedChannel = (client: StreamChat, cid?: string) => { + if (!cid) return; + + const channel = client.activeChannels[cid]; + + // Only channels that were actually watched need to be stopped; calling + // stopWatching() on an uninitialized channel would throw. + if (!channel?.initialized) return; + + // The current user may already have been removed server-side, so ignore failures. + channel.stopWatching().catch(() => undefined); +}; + export const useChannelListShapeDefaults = () => { const { client } = useChatContext(); @@ -251,8 +271,10 @@ export const useChannelListShapeDefaults = () => { setChannels((channels) => channels.filter((channel) => channel.cid !== event.channel?.cid), ); + + stopWatchingRemovedChannel(client, event.channel?.cid); }, - [], + [client], ); const handleMemberUpdated = useCallback( @@ -327,6 +349,21 @@ export const useChannelListShapeDefaults = () => { [client], ); + const handleMemberRemoved = useCallback( + ({ event, setChannels }: HandleMemberRemovedParameters) => { + // React only when the current user is the member being removed; this event + // also fires when other members leave channels we are part of. + if (event.member?.user?.id !== client.userID) { + return; + } + + setChannels((channels) => channels.filter((channel) => channel.cid !== event.cid)); + + stopWatchingRemovedChannel(client, event.cid); + }, + [client], + ); + const handleChannelDeleted = useCallback( (p: HandleChannelDeletedParameters) => shared(p), [], @@ -452,6 +489,7 @@ export const useChannelListShapeDefaults = () => { handleChannelTruncated, handleChannelUpdated, handleChannelVisible, + handleMemberRemoved, handleMemberUpdated, handleMessageNew, handleNotificationAddedToChannel, @@ -465,6 +503,7 @@ export const useChannelListShapeDefaults = () => { handleChannelTruncated, handleChannelUpdated, handleChannelVisible, + handleMemberRemoved, handleMemberUpdated, handleMessageNew, handleNotificationAddedToChannel, @@ -618,6 +657,9 @@ export const usePrepareShapeHandlers = ({ sort, }); break; + case 'member.removed': + defaults.handleMemberRemoved({ event, setChannels }); + break; default: break; } From 995329ac3736b96e06f591ee4941ddc15cb25539 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 29 Jun 2026 15:44:01 +0200 Subject: [PATCH 04/10] fix(Channel): guard channel methods against a disconnected client channel.lastRead() runs during render (useCreateChannelStateContext) and throws "You can't use a channel after client.disconnect() was called" once the shared client is disconnected - crashing the render, with no error boundary to catch it. Guard the call with channel.disconnected. Also skip loadMore's pagination query while the client is disconnected, mirroring the existing markRead guard. Closes #2393 --- src/components/Channel/Channel.tsx | 1 + .../Channel/__tests__/Channel.test.tsx | 42 +++++++++++++++++++ .../hooks/useCreateChannelStateContext.ts | 6 ++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index c2a1cfde63..e070a39eaf 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 4bebd0400e..04f8be01ec 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 0d7b8687e3..07232a14c2 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); From 10b96a31a36cc7195b26a390835fa1792ecc9114 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 30 Jun 2026 13:37:50 +0200 Subject: [PATCH 05/10] revert: drop ChannelList stopWatching-on-removal fix (#2599) Browser verification showed the stopWatching-based fix does 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-adds the removed channel to the list - the exact reported symptom. The correct fix belongs in stream-chat core: evict activeChannels[cid] on notification.removed_from_channel / member.removed, mirroring the existing channel.deleted handling. Dropping #2599 from this PR until that core change lands. #2474, #2441 and #2393 are unaffected. --- .../__tests__/ChannelList.test.tsx | 114 ------------------ .../ChannelList/hooks/useChannelListShape.ts | 46 +------ 2 files changed, 2 insertions(+), 158 deletions(-) diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx index c7999d8082..95c42f2057 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -1469,120 +1469,6 @@ describe('ChannelList', () => { const results = await axe(container); expect(results).toHaveNoViolations(); }); - - it('stops watching the channel it removes so later events cannot re-add it (#2599)', async () => { - const { getByRole } = await render( - - - - - , - ); - await waitFor(() => { - expect(getByRole('list')).toBeInTheDocument(); - }); - - const removedChannel = chatClient.activeChannels[testChannel3.channel.cid]; - expect(removedChannel).toBeDefined(); - const stopWatchingSpy = vi - .spyOn(removedChannel, 'stopWatching') - .mockResolvedValue(fromPartial({})); - - act(() => - dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel), - ); - - expect(stopWatchingSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('member.removed', () => { - const channelListProps = { - filters: {}, - options: { limit: 25, message_limit: 25 }, - }; - - const dispatchMemberRemoved = (channel: ChannelAPIResponse, userId?: string) => - chatClient.dispatchEvent( - fromPartial({ - channel_id: channel.channel.id, - channel_type: channel.channel.type, - cid: channel.channel.cid, - member: { user: { id: userId ?? chatClient.userID } }, - type: 'member.removed', - }), - ); - - beforeEach(() => { - useMockedApis(chatClient, [ - queryChannelsApi([testChannel1, testChannel2, testChannel3]), - ]); - }); - - it('removes the channel and stops watching it when the current user is removed (#2599)', async () => { - const { getByRole, getByTestId, queryByTestId } = await render( - - - - - , - ); - await waitFor(() => { - expect(getByRole('list')).toBeInTheDocument(); - }); - expect(getByTestId(testChannel3.channel.id)).toBeInTheDocument(); - - const removedChannel = chatClient.activeChannels[testChannel3.channel.cid]; - const stopWatchingSpy = vi - .spyOn(removedChannel, 'stopWatching') - .mockResolvedValue(fromPartial({})); - - act(() => dispatchMemberRemoved(testChannel3)); - - await waitFor(() => { - expect(queryByTestId(testChannel3.channel.id)).not.toBeInTheDocument(); - }); - expect(stopWatchingSpy).toHaveBeenCalledTimes(1); - }); - - it('ignores member.removed for a different user', async () => { - const { getByRole, getByTestId } = await render( - - - - - , - ); - await waitFor(() => { - expect(getByRole('list')).toBeInTheDocument(); - }); - - const otherChannel = chatClient.activeChannels[testChannel3.channel.cid]; - const stopWatchingSpy = vi - .spyOn(otherChannel, 'stopWatching') - .mockResolvedValue(fromPartial({})); - - act(() => dispatchMemberRemoved(testChannel3, 'someone-else')); - - // channel must remain and must not be unwatched - expect(getByTestId(testChannel3.channel.id)).toBeInTheDocument(); - expect(stopWatchingSpy).not.toHaveBeenCalled(); - }); }); describe('channel.updated', () => { diff --git a/src/components/ChannelList/hooks/useChannelListShape.ts b/src/components/ChannelList/hooks/useChannelListShape.ts index b017dceaec..fa5feb7f94 100644 --- a/src/components/ChannelList/hooks/useChannelListShape.ts +++ b/src/components/ChannelList/hooks/useChannelListShape.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import type { Channel, Event, StreamChat } from 'stream-chat'; +import type { Channel, Event } from 'stream-chat'; import type { Dispatch, SetStateAction } from 'react'; import { @@ -57,8 +57,6 @@ type HandleMemberUpdatedParameters = BaseParameters & { lockChannelOrder: boolean; } & Required>; -type HandleMemberRemovedParameters = BaseParameters; - type HandleChannelDeletedParameters = BaseParameters & RepeatedParameters; type HandleChannelHiddenParameters = BaseParameters & RepeatedParameters; @@ -89,24 +87,6 @@ const shared = ({ }); }; -/** - * Stops watching a channel that has just been removed from the list. Without - * this the client keeps watching the channel, so a later `message.new` / - * `notification.message_new` re-adds it to the list until a hard refresh (#2599). - */ -const stopWatchingRemovedChannel = (client: StreamChat, cid?: string) => { - if (!cid) return; - - const channel = client.activeChannels[cid]; - - // Only channels that were actually watched need to be stopped; calling - // stopWatching() on an uninitialized channel would throw. - if (!channel?.initialized) return; - - // The current user may already have been removed server-side, so ignore failures. - channel.stopWatching().catch(() => undefined); -}; - export const useChannelListShapeDefaults = () => { const { client } = useChatContext(); @@ -271,10 +251,8 @@ export const useChannelListShapeDefaults = () => { setChannels((channels) => channels.filter((channel) => channel.cid !== event.channel?.cid), ); - - stopWatchingRemovedChannel(client, event.channel?.cid); }, - [client], + [], ); const handleMemberUpdated = useCallback( @@ -349,21 +327,6 @@ export const useChannelListShapeDefaults = () => { [client], ); - const handleMemberRemoved = useCallback( - ({ event, setChannels }: HandleMemberRemovedParameters) => { - // React only when the current user is the member being removed; this event - // also fires when other members leave channels we are part of. - if (event.member?.user?.id !== client.userID) { - return; - } - - setChannels((channels) => channels.filter((channel) => channel.cid !== event.cid)); - - stopWatchingRemovedChannel(client, event.cid); - }, - [client], - ); - const handleChannelDeleted = useCallback( (p: HandleChannelDeletedParameters) => shared(p), [], @@ -489,7 +452,6 @@ export const useChannelListShapeDefaults = () => { handleChannelTruncated, handleChannelUpdated, handleChannelVisible, - handleMemberRemoved, handleMemberUpdated, handleMessageNew, handleNotificationAddedToChannel, @@ -503,7 +465,6 @@ export const useChannelListShapeDefaults = () => { handleChannelTruncated, handleChannelUpdated, handleChannelVisible, - handleMemberRemoved, handleMemberUpdated, handleMessageNew, handleNotificationAddedToChannel, @@ -657,9 +618,6 @@ export const usePrepareShapeHandlers = ({ sort, }); break; - case 'member.removed': - defaults.handleMemberRemoved({ event, setChannels }); - break; default: break; } From d49db734a59bfc7e14f40dce63462965175be14e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 30 Jun 2026 15:38:39 +0200 Subject: [PATCH 06/10] fix(ChannelListItem): track current channel in mute-status subscription The notification.channel_mutes_updated effect depended on [muted], so it re-subscribed on its own output and could capture a stale channel when the channel prop changed without the muted value changing. Depend on [channel, client] instead and clean up via the unsubscribe handle returned by client.on(), dropping the exhaustive-deps override. --- .../ChannelListItem/hooks/useIsChannelMuted.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts index 7423e1ec8a..7b2ce8c0af 100644 --- a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts +++ b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts @@ -21,12 +21,12 @@ export const useIsChannelMuted = (channel: Channel) => { const [muted, setMuted] = useState(() => getMuteStatus(channel)); useEffect(() => { - const handleEvent = () => setMuted(getMuteStatus(channel)); + 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; }; From d5d874bfdd86be5c219a1de631eabc3d5a67a5a8 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 30 Jun 2026 16:06:37 +0200 Subject: [PATCH 07/10] fix(ChannelListItem): guard mute status against a disconnected channel An initialized channel whose client has disconnected still routes channel.muteStatus() through channel.getClient(), which throws "You can't use a channel after client.disconnect() was called". Guard channel.disconnected alongside channel.initialized in getMuteStatus so rendering a ChannelListItem with a disconnected channel no longer crashes the app (same failure class as the #2393 Channel-state guard). --- .../__tests__/useIsChannelMuted.test.tsx | 21 +++++++++++++++++++ .../hooks/useIsChannelMuted.ts | 14 ++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx index da668d783f..50a0c08f50 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx @@ -54,4 +54,25 @@ describe('useIsChannelMuted', () => { 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 7b2ce8c0af..88d2c15ee4 100644 --- a/src/components/ChannelListItem/hooks/useIsChannelMuted.ts +++ b/src/components/ChannelListItem/hooks/useIsChannelMuted.ts @@ -5,13 +5,17 @@ import { useChatContext } from '../../../context/ChatContext'; import type { Channel } from 'stream-chat'; /** - * `channel.muteStatus()` throws when 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 - so guard against it to avoid crashing the whole app (#2474). + * `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.initialized && !channel.disconnected ? channel.muteStatus() : { createdAt: null, expiresAt: null, muted: false }; From 752010c63ab06d6f334265c97d5ba4a6e09df47b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 30 Jun 2026 16:08:36 +0200 Subject: [PATCH 08/10] test(ChannelList): assert removed channels are evicted from activeChannels (#2599) Regression test for #2599: removing the current user from a channel must evict it from client.activeChannels, otherwise recoverState() re-watches it on the next reconnect (recoverStateOnReconnect defaults to true) and the list resurrects it. The eviction itself lives in stream-chat core (GetStream/stream-chat-js#1788). This test is red until that fix ships in a stream-chat release and the dependency here is bumped to it; the bump + green run will accompany closing #2599. --- .../__tests__/ChannelList.test.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx index 95c42f2057..d1e5ce12cc 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -1469,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', () => { From b97f92e9ec5f67846cf2ac83fa7c86fd24275d6d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 1 Jul 2026 11:18:45 +0200 Subject: [PATCH 09/10] fix(deps): require stream-chat ^9.49.0 to evict removed channels (#2599) stream-chat 9.49.0 (GetStream/stream-chat-js#1788) evicts a channel from client.activeChannels on notification.removed_from_channel, so a reconnect no longer re-watches a channel the current user was removed from. Bumping the peer and dev requirements makes ChannelList removal durable and turns the #2599 regression test green. --- package.json | 4 ++-- yarn.lock | 26 ++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 22d1bfbaa8..ce2e0b998e 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/yarn.lock b/yarn.lock index fe3924585f..c38cc050cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -10169,6 +10169,28 @@ __metadata: languageName: node linkType: hard +"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" + axios: "npm:^1.16.1" + base64-js: "npm:^1.5.1" + form-data: "npm:^4.0.5" + isomorphic-ws: "npm:^5.0.0" + jsonwebtoken: "npm:^9.0.3" + linkifyjs: "npm:^4.3.3" + ws: "npm:^8.20.1" + dependenciesMeta: + esbuild: + built: true + husky: + built: true + checksum: 10c0/2fceb2434d69523b09b9c6419ab46f2c181aa2ef0b269ed388ce53254f8a4c618eb14ead46d2ae5245c2070c33ab35d33ac1c3671610774503da8a160d71e985 + languageName: node + linkType: hard + "stream-combiner2@npm:~1.1.1": version: 1.1.1 resolution: "stream-combiner2@npm:1.1.1" From 96144608a06ccbd27b454ab78ef8a5034e34895b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 1 Jul 2026 11:28:01 +0200 Subject: [PATCH 10/10] chore(examples): bump stream-chat to ^9.49.0 to match the SDK The SDK now requires stream-chat ^9.49.0; leaving the examples at ^9.47.0 made yarn keep a second stream-chat copy, breaking the vite example build with duplicate-Thread type errors (subscribeRepliesUnread missing). Aligning the example deps dedupes to a single 9.49.0. --- examples/tutorial/package.json | 2 +- examples/vite/package.json | 2 +- yarn.lock | 26 ++------------------------ 3 files changed, 4 insertions(+), 26 deletions(-) diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index bb9a3b0141..7a799514d5 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 0e734adf1c..37ea3cf56f 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/yarn.lock b/yarn.lock index c38cc050cf..1848a9f82a 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" @@ -10147,28 +10147,6 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.47.0": - version: 9.47.0 - resolution: "stream-chat@npm:9.47.0" - dependencies: - "@types/jsonwebtoken": "npm:^9.0.8" - "@types/ws": "npm:^8.18.1" - axios: "npm:^1.16.1" - base64-js: "npm:^1.5.1" - form-data: "npm:^4.0.5" - isomorphic-ws: "npm:^5.0.0" - jsonwebtoken: "npm:^9.0.3" - linkifyjs: "npm:^4.3.3" - ws: "npm:^8.20.1" - dependenciesMeta: - esbuild: - built: true - husky: - built: true - checksum: 10c0/6f1a84f31047d0ccaf764ee106a276d7361b1582083c762cecd0e427d37ff6b9bfc69decaa856be0ab81d42f53beea12a231bc207d73dcbf28126abc9e28d9f3 - languageName: node - linkType: hard - "stream-chat@npm:^9.49.0": version: 9.49.0 resolution: "stream-chat@npm:9.49.0"