From 36d2a37b5e60c31125bd05ef42ad02399ed232b0 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 5 Aug 2026 13:53:48 +0200 Subject: [PATCH 1/5] fix(Channel): guard render-phase channel.getConfig() against disconnected channels ChannelInner evaluated channel.getConfig() as an eager useState argument, which throws "You can't use a channel after client.disconnect() was called" once the channel is disconnected (current user removed / channel deleted). The throw happened during render, tearing down the surrounding subtree. Add an internal getChannelConfig() guard, apply it at every render-phase call site, use a lazy initializer so it no longer re-runs on every render, and early-return from handleEvent for a disconnected channel. Fixing Channel alone is not enough: the crash relocates to AttachmentSelector once ChannelInner stops throwing and its subtree renders. Closes #3254 --- src/components/Channel/Channel.tsx | 8 +- .../Channel/__tests__/Channel.test.tsx | 87 ++++++++++++++++++- .../AttachmentSelector/AttachmentSelector.tsx | 3 +- .../MessageComposer/MessageComposer.tsx | 7 ++ .../__tests__/AttachmentSelector.test.tsx | 21 +++++ .../__tests__/MessageInput.test.tsx | 17 ++++ .../useMessageComposerCommands.test.tsx | 13 +++ .../hooks/useMessageComposerCommands.ts | 3 +- .../hooks/__tests__/useMarkRead.test.tsx | 21 +++++ .../MessageList/hooks/useMarkRead.ts | 3 +- src/utils/__tests__/getChannelConfig.test.ts | 29 +++++++ src/utils/getChannelConfig.ts | 19 ++++ 12 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/utils/__tests__/getChannelConfig.test.ts create mode 100644 src/utils/getChannelConfig.ts diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 9f7c4b9a8..e7b80f4f6 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -77,6 +77,7 @@ import { } from './utils'; import { useThreadContext } from '../Threads'; import { getChannel } from '../../utils'; +import { getChannelConfig } from '../../utils/getChannelConfig'; import type { ChannelUnreadUiState, ImageAttachmentSizeHandler, @@ -248,7 +249,7 @@ const ChannelInner = ( const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); const thread = useThreadContext(); - const [channelConfig, setChannelConfig] = useState(channel.getConfig()); + const [channelConfig, setChannelConfig] = useState(() => getChannelConfig(channel)); const [channelUnreadUiState, _setChannelUnreadUiState] = useState(); @@ -357,6 +358,11 @@ const ChannelInner = ( ); const handleEvent = async (event: Event) => { + // Client-level subscriptions keep delivering events after the channel has + // been disconnected (current user removed / channel deleted). Reading from + // or querying such a channel throws, so there is nothing useful left to do. + if (channel.disconnected) return; + if (event.message) { dispatch({ channel, diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 862cd8f73..800047094 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -1,9 +1,12 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { nanoid } from 'nanoid'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { ErrorFromResponse, SearchController } from 'stream-chat'; import type { + ChannelAPIResponse, Channel as ChannelType, + Event, + GiphyVersions, LocalMessage, Message, MessageResponse, @@ -823,6 +826,88 @@ describe('Channel', () => { expect(querySpy).not.toHaveBeenCalled(); }); + + it('does not throw during render when the channel is disconnected (#3254)', async () => { + // initClient stubs channel.getConfig; restore the real implementation so + // that the disconnect guard inside channel.getClient() is reachable + vi.mocked(channel.getConfig).mockRestore(); + + let channelConfig: ChannelStateContextValue['channelConfig'] | 'unset' = 'unset'; + const ConfigProbe = () => { + channelConfig = useChannelStateContext().channelConfig; + return
probe
; + }; + + let setGiphyVersion: (version: GiphyVersions) => void = () => {}; + const Wrapper = () => { + const [giphyVersion, _setGiphyVersion] = useState('fixed_height'); + setGiphyVersion = _setGiphyVersion; + return ( + + + + + + ); + }; + + await act(() => { + render(); + }); + await waitFor(() => expect(screen.getByText('probe')).toBeInTheDocument()); + + // the channel is mounted and initialized; it then gets disconnected, as it + // would be by channel.deleted / notification.removed_from_channel + channel.disconnected = true; + + // changing a Channel prop bypasses React.memo and re-renders ChannelInner + expect(() => + act(() => { + setGiphyVersion('original'); + }), + ).not.toThrow(); + + // the subtree survives, and the config captured while connected is retained + expect(screen.getByText('probe')).toBeInTheDocument(); + expect(channelConfig).toEqual(expect.objectContaining({ read_events: true })); + }); + + it('provides an undefined channelConfig when mounting an already disconnected channel (#3254)', async () => { + // the channel must already be initialized, otherwise Channel tries to query + // it on mount and legitimately ends up in its error state instead + await channel.watch(); + vi.mocked(channel.getConfig).mockRestore(); + channel.disconnected = true; + + let channelConfig: ChannelStateContextValue['channelConfig'] | 'unset' = 'unset'; + const ConfigProbe = () => { + channelConfig = useChannelStateContext().channelConfig; + return
probe
; + }; + + await renderComponent({ channel, chatClient, children: }); + + await waitFor(() => expect(screen.getByText('probe')).toBeInTheDocument()); + expect(channelConfig).toBeUndefined(); + }); + + it('does not query a disconnected channel on user.deleted (#3254)', async () => { + await renderComponent({ channel, chatClient }); + + const querySpy = vi + .spyOn(channel, 'query') + .mockResolvedValue(fromPartial({})); + channel.disconnected = true; + + // client-level subscriptions keep delivering events to the mounted Channel + // even after stream-chat drops the channel from client.activeChannels + await act(async () => { + chatClient.dispatchEvent(fromPartial({ type: 'user.deleted' })); + await Promise.resolve(); + }); + + expect(querySpy).not.toHaveBeenCalled(); + }); }); describe('Children that consume the contexts set in Channel', () => { diff --git a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx index e1c3c16be..06d9ea800 100644 --- a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx @@ -37,6 +37,7 @@ import { AttachmentSelectorContextProvider, useAttachmentSelectorContext, } from '../../../context/AttachmentSelectorContext'; +import { getChannelConfig } from '../../../utils/getChannelConfig'; import { useStableId } from '../../UtilityComponents/useStableId'; import { useInertWhenHidden } from '../../Accessibility'; import { useStateStore } from '../../../store'; @@ -283,7 +284,7 @@ const useAttachmentSelectorActionsFiltered = (original: AttachmentSelectorAction const { channelCapabilities } = useChannelStateContext(); const { isUploadEnabled } = useAttachmentManagerState(); const messageComposer = useMessageComposerController(); - const channelConfig = messageComposer.channel.getConfig(); + const channelConfig = getChannelConfig(messageComposer.channel); return useMemo( () => diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index 70888f629..cf00aa9c7 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -95,6 +95,13 @@ const MessageComposerProvider = (props: PropsWithChildren) useEffect( () => () => { + // A disconnected channel (current user removed / channel deleted) cannot + // accept a draft, and neither createDraft() nor clear() are safe to call: + // both reach channel.getConfig(), which throws "You can't use a channel + // after client.disconnect() was called". The composer is going away with + // the channel, so there is nothing left worth persisting or resetting. + if (messageComposer.channel.disconnected) return; + messageComposer.createDraft().finally(() => messageComposer.clear()); }, [messageComposer], diff --git a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx index 4da5a314e..ced6a83f0 100644 --- a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx +++ b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx @@ -774,6 +774,27 @@ describe('AttachmentSelector', () => { expect(screen.getByTestId(SHARE_LOCATION_DIALOG_TEST_ID)).toBeInTheDocument(); }); }); + + it('does not throw when the channel disconnects while mounted (#3254)', async () => { + const { channel } = await renderComponent(); + + // initClientWithChannels stubs channel.getConfig; restore the real + // implementation so that the disconnect guard in getClient() is reachable + vi.mocked(channel.getConfig).mockRestore(); + channel.disconnected = true; + + // opening the menu re-renders the selector, which re-reads the channel config + await expect(invokeMenu()).resolves.toBeUndefined(); + + // no config means no available actions, so the selector renders nothing + // instead of tearing down the surrounding subtree + expect( + screen.queryByTestId(SIMPLE_ATTACHMENT_SELECTOR_TEST_ID), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID), + ).not.toBeInTheDocument(); + }); }); const AttachmentSelectorInitiationButtonContents = () => ( diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 528f807cb..fcdd28b44 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -2130,3 +2130,20 @@ describe(`MessageInputFlat`, () => { }); }); }); + +describe('MessageComposer draft creation on unmount', () => { + afterEach(tearDown); + + it('does not create a draft for a disconnected channel (#3254)', async () => { + const { channel, unmount } = await renderComponent(); + const createDraftSpy = vi.spyOn(channel!.messageComposer, 'createDraft'); + + channel!.disconnected = true; + + await act(() => { + unmount(); + }); + + expect(createDraftSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx index d39cc518f..53a39d411 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx @@ -116,4 +116,17 @@ describe('useMessageComposerCommands', () => { { command: expect.objectContaining({ name: 'ban' }), enabled: false }, ]); }); + it('returns no commands for a disconnected channel without calling getConfig (#3254)', () => { + // channel.getConfig() calls channel.getClient(), which throws once the + // channel is disconnected + vi.spyOn(messageComposer.channel, 'getConfig').mockImplementation(() => { + throw new Error("You can't use a channel after client.disconnect() was called"); + }); + (messageComposer.channel as { disconnected?: boolean }).disconnected = true; + + const { result } = renderHook(() => useMessageComposerCommands()); + + expect(result.current).toEqual([]); + expect(messageComposer.channel.getConfig).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/MessageComposer/hooks/useMessageComposerCommands.ts b/src/components/MessageComposer/hooks/useMessageComposerCommands.ts index 94e7b41fe..706491ed5 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerCommands.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerCommands.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { CommandResponse, MessageComposerState } from 'stream-chat'; import { useStateStore } from '../../../store'; +import { getChannelConfig } from '../../../utils/getChannelConfig'; import { useMessageComposerController } from './useMessageComposerController'; const messageComposerStateSelector = ({ @@ -19,7 +20,7 @@ export type MessageComposerCommand = { export const useMessageComposerCommands = () => { const messageComposer = useMessageComposerController(); - const channelConfig = messageComposer.channel.getConfig(); + const channelConfig = getChannelConfig(messageComposer.channel); const { editedMessage, quotedMessage } = useStateStore( messageComposer.state, messageComposerStateSelector, diff --git a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx index ac289b118..592c988fd 100644 --- a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx @@ -834,4 +834,25 @@ describe('useMarkRead', () => { }); }); }); + + it('does not throw when the channel is disconnected (#3254)', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + // initClientWithChannels stubs channel.getConfig; restore the real + // implementation so that the disconnect guard in getClient() is reachable + vi.mocked(channel.getConfig).mockRestore(); + channel.disconnected = true; + + expect(() => + render({ + channel, + client, + params: { isMessageListScrolledToBottom: true, messageListIsThread: false }, + }), + ).not.toThrow(); + + expect(markRead).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index e67e0f2ee..c2c76a00f 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -5,6 +5,7 @@ import { useChatContext, } from '../../../context'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import { getChannelConfig } from '../../../utils/getChannelConfig'; const hasReadLastMessage = (channel: Channel, userId: string) => { const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; @@ -38,7 +39,7 @@ export const useMarkRead = ({ useEffect(() => { const unreadNotificationSupported = - channel.getConfig()?.read_events || client.options.isLocalUnreadCountEnabled; + getChannelConfig(channel)?.read_events || client.options.isLocalUnreadCountEnabled; if (!unreadNotificationSupported) return; diff --git a/src/utils/__tests__/getChannelConfig.test.ts b/src/utils/__tests__/getChannelConfig.test.ts new file mode 100644 index 000000000..f6727cf6e --- /dev/null +++ b/src/utils/__tests__/getChannelConfig.test.ts @@ -0,0 +1,29 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Channel, ChannelConfigWithInfo } from 'stream-chat'; +import { describe, expect, it, vi } from 'vitest'; +import { getChannelConfig } from '../getChannelConfig'; + +const config = fromPartial({ read_events: true }); + +describe('getChannelConfig', () => { + it('returns the channel config for a connected channel', () => { + const channel = fromPartial({ + disconnected: false, + getConfig: () => config, + }); + + expect(getChannelConfig(channel)).toBe(config); + }); + + it('returns undefined for a disconnected channel without calling getConfig', () => { + // channel.getConfig() calls channel.getClient(), which throws + // "You can't use a channel after client.disconnect() was called" + const getConfig = vi.fn(() => { + throw new Error("You can't use a channel after client.disconnect() was called"); + }); + const channel = fromPartial({ disconnected: true, getConfig }); + + expect(getChannelConfig(channel)).toBeUndefined(); + expect(getConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/getChannelConfig.ts b/src/utils/getChannelConfig.ts new file mode 100644 index 000000000..4ba39dbf2 --- /dev/null +++ b/src/utils/getChannelConfig.ts @@ -0,0 +1,19 @@ +import type { Channel, ChannelConfigWithInfo } from 'stream-chat'; + +/** + * `channel.getConfig()` calls `channel.getClient()`, which throws + * "You can't use a channel after client.disconnect() was called" once the + * channel is disconnected (e.g. the current user was removed from the channel + * or the channel was deleted - see `channel.deleted`, + * `notification.channel_deleted` and `notification.removed_from_channel`). + * + * The `disconnected` flag is flipped from an asynchronous WS event, so there is + * always a window between the flag becoming true and React unmounting the + * subtree that renders the channel. Any render inside that window would throw, + * so callers must never reach `getConfig()` for a disconnected channel. + * + * `undefined` is already part of `getConfig()`'s return type, so consumers need + * no extra handling beyond what they do for a not-yet-configured channel. + */ +export const getChannelConfig = (channel: Channel): ChannelConfigWithInfo | undefined => + channel.disconnected ? undefined : channel.getConfig(); From debef04731c134832450083d6914f06dcfa4b4ee Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 5 Aug 2026 17:35:55 +0200 Subject: [PATCH 2/5] fix(Channel): guard loadMoreNewer against disconnected channels loadMore already short-circuits on channel.disconnected, but loadMoreNewer checked only online.current / navigator.onLine / hasNext and went on to call channel.query(), which throws for a disconnected channel. The existing try/catch swallowed it into a console.warn plus a spurious setLoadingMoreNewer dispatch on every scroll-to-bottom. --- src/components/Channel/Channel.tsx | 1 + .../Channel/__tests__/Channel.test.tsx | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index e7b80f4f6..87e480fa1 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -666,6 +666,7 @@ const ChannelInner = ( const loadMoreNewer = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { if ( + channel.disconnected || !online.current || !window.navigator.onLine || !channel.state.messagePagination.hasNext diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 800047094..a394a1a7e 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -827,6 +827,28 @@ describe('Channel', () => { expect(querySpy).not.toHaveBeenCalled(); }); + it('does not paginate newer (query) when the client is disconnected', async () => { + let loadMoreNewer: ChannelActionContextValue['loadMoreNewer'] | undefined; + await renderComponent( + { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, + (c) => { + loadMoreNewer = c.loadMoreNewer; + }, + ); + + // loadMoreNewer bails out early unless there is a newer page to fetch + channel.state.messagePagination.hasNext = true; + + const querySpy = vi.spyOn(channel, 'query'); + channel.disconnected = true; + + await act(async () => { + await loadMoreNewer?.(); + }); + + expect(querySpy).not.toHaveBeenCalled(); + }); + it('does not throw during render when the channel is disconnected (#3254)', async () => { // initClient stubs channel.getConfig; restore the real implementation so // that the disconnect guard inside channel.getClient() is reachable From 0bb7fb773a28ee8233f0fe9c8f2250ad6ff56420 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 6 Aug 2026 10:01:16 +0200 Subject: [PATCH 3/5] docs(Channel): trim verbose comments added for the disconnect guards Reduce the getChannelConfig JSDoc and the inline comments to the core statement: what throws, when, and what is returned instead. --- src/components/Channel/Channel.tsx | 5 ++--- .../Channel/__tests__/Channel.test.tsx | 11 ++--------- .../MessageComposer/MessageComposer.tsx | 7 ++----- .../__tests__/AttachmentSelector.test.tsx | 4 +--- .../useMessageComposerCommands.test.tsx | 2 -- .../hooks/__tests__/useMarkRead.test.tsx | 3 +-- src/utils/__tests__/getChannelConfig.test.ts | 2 -- src/utils/getChannelConfig.ts | 16 +++------------- 8 files changed, 11 insertions(+), 39 deletions(-) diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 87e480fa1..9d031d41c 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -358,9 +358,8 @@ const ChannelInner = ( ); const handleEvent = async (event: Event) => { - // Client-level subscriptions keep delivering events after the channel has - // been disconnected (current user removed / channel deleted). Reading from - // or querying such a channel throws, so there is nothing useful left to do. + // client-level subscriptions keep firing after disconnect, and reading from + // or querying a disconnected channel throws if (channel.disconnected) return; if (event.message) { diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index a394a1a7e..8a7121a41 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -850,8 +850,7 @@ describe('Channel', () => { }); it('does not throw during render when the channel is disconnected (#3254)', async () => { - // initClient stubs channel.getConfig; restore the real implementation so - // that the disconnect guard inside channel.getClient() is reachable + // initClient stubs getConfig; restore it so the disconnect guard is reachable vi.mocked(channel.getConfig).mockRestore(); let channelConfig: ChannelStateContextValue['channelConfig'] | 'unset' = 'unset'; @@ -878,8 +877,6 @@ describe('Channel', () => { }); await waitFor(() => expect(screen.getByText('probe')).toBeInTheDocument()); - // the channel is mounted and initialized; it then gets disconnected, as it - // would be by channel.deleted / notification.removed_from_channel channel.disconnected = true; // changing a Channel prop bypasses React.memo and re-renders ChannelInner @@ -889,14 +886,12 @@ describe('Channel', () => { }), ).not.toThrow(); - // the subtree survives, and the config captured while connected is retained expect(screen.getByText('probe')).toBeInTheDocument(); expect(channelConfig).toEqual(expect.objectContaining({ read_events: true })); }); it('provides an undefined channelConfig when mounting an already disconnected channel (#3254)', async () => { - // the channel must already be initialized, otherwise Channel tries to query - // it on mount and legitimately ends up in its error state instead + // must be initialized, otherwise Channel queries on mount and errors instead await channel.watch(); vi.mocked(channel.getConfig).mockRestore(); channel.disconnected = true; @@ -921,8 +916,6 @@ describe('Channel', () => { .mockResolvedValue(fromPartial({})); channel.disconnected = true; - // client-level subscriptions keep delivering events to the mounted Channel - // even after stream-chat drops the channel from client.activeChannels await act(async () => { chatClient.dispatchEvent(fromPartial({ type: 'user.deleted' })); await Promise.resolve(); diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index cf00aa9c7..165239fe0 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -95,11 +95,8 @@ const MessageComposerProvider = (props: PropsWithChildren) useEffect( () => () => { - // A disconnected channel (current user removed / channel deleted) cannot - // accept a draft, and neither createDraft() nor clear() are safe to call: - // both reach channel.getConfig(), which throws "You can't use a channel - // after client.disconnect() was called". The composer is going away with - // the channel, so there is nothing left worth persisting or resetting. + // both createDraft() and clear() reach channel.getConfig(), which throws + // for a disconnected channel if (messageComposer.channel.disconnected) return; messageComposer.createDraft().finally(() => messageComposer.clear()); diff --git a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx index ced6a83f0..4032d66f7 100644 --- a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx +++ b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx @@ -778,8 +778,7 @@ describe('AttachmentSelector', () => { it('does not throw when the channel disconnects while mounted (#3254)', async () => { const { channel } = await renderComponent(); - // initClientWithChannels stubs channel.getConfig; restore the real - // implementation so that the disconnect guard in getClient() is reachable + // initClientWithChannels stubs getConfig; restore it so the guard is reachable vi.mocked(channel.getConfig).mockRestore(); channel.disconnected = true; @@ -787,7 +786,6 @@ describe('AttachmentSelector', () => { await expect(invokeMenu()).resolves.toBeUndefined(); // no config means no available actions, so the selector renders nothing - // instead of tearing down the surrounding subtree expect( screen.queryByTestId(SIMPLE_ATTACHMENT_SELECTOR_TEST_ID), ).not.toBeInTheDocument(); diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx index 53a39d411..26ce777ca 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx @@ -117,8 +117,6 @@ describe('useMessageComposerCommands', () => { ]); }); it('returns no commands for a disconnected channel without calling getConfig (#3254)', () => { - // channel.getConfig() calls channel.getClient(), which throws once the - // channel is disconnected vi.spyOn(messageComposer.channel, 'getConfig').mockImplementation(() => { throw new Error("You can't use a channel after client.disconnect() was called"); }); diff --git a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx index 592c988fd..6f7a8209d 100644 --- a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx @@ -840,8 +840,7 @@ describe('useMarkRead', () => { channels: [channel], client, } = await initClientWithChannels(); - // initClientWithChannels stubs channel.getConfig; restore the real - // implementation so that the disconnect guard in getClient() is reachable + // initClientWithChannels stubs getConfig; restore it so the guard is reachable vi.mocked(channel.getConfig).mockRestore(); channel.disconnected = true; diff --git a/src/utils/__tests__/getChannelConfig.test.ts b/src/utils/__tests__/getChannelConfig.test.ts index f6727cf6e..bcffb20b2 100644 --- a/src/utils/__tests__/getChannelConfig.test.ts +++ b/src/utils/__tests__/getChannelConfig.test.ts @@ -16,8 +16,6 @@ describe('getChannelConfig', () => { }); it('returns undefined for a disconnected channel without calling getConfig', () => { - // channel.getConfig() calls channel.getClient(), which throws - // "You can't use a channel after client.disconnect() was called" const getConfig = vi.fn(() => { throw new Error("You can't use a channel after client.disconnect() was called"); }); diff --git a/src/utils/getChannelConfig.ts b/src/utils/getChannelConfig.ts index 4ba39dbf2..6a319c1bb 100644 --- a/src/utils/getChannelConfig.ts +++ b/src/utils/getChannelConfig.ts @@ -1,19 +1,9 @@ import type { Channel, ChannelConfigWithInfo } from 'stream-chat'; /** - * `channel.getConfig()` calls `channel.getClient()`, which throws - * "You can't use a channel after client.disconnect() was called" once the - * channel is disconnected (e.g. the current user was removed from the channel - * or the channel was deleted - see `channel.deleted`, - * `notification.channel_deleted` and `notification.removed_from_channel`). - * - * The `disconnected` flag is flipped from an asynchronous WS event, so there is - * always a window between the flag becoming true and React unmounting the - * subtree that renders the channel. Any render inside that window would throw, - * so callers must never reach `getConfig()` for a disconnected channel. - * - * `undefined` is already part of `getConfig()`'s return type, so consumers need - * no extra handling beyond what they do for a not-yet-configured channel. + * `channel.getConfig()` throws once the channel is disconnected (current user + * removed from the channel, channel deleted). Returns `undefined` instead, + * which is already part of `getConfig()`'s return type. */ export const getChannelConfig = (channel: Channel): ChannelConfigWithInfo | undefined => channel.disconnected ? undefined : channel.getConfig(); From 91d0e95a1c9b52cf784c0714fb14b589fde8f62a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 6 Aug 2026 11:19:24 +0200 Subject: [PATCH 4/5] test(Channel): temporarily drop loadMoreNewer disconnect test Experiment only, to be reverted. Keeps the loadMoreNewer production guard and removes only the test added alongside it, to determine whether the VirtualizedMessageList snapshot failure on CI comes from the guard itself or from the scheduling shift caused by one extra test. --- .../Channel/__tests__/Channel.test.tsx | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 8a7121a41..d0d53c289 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -827,28 +827,6 @@ describe('Channel', () => { expect(querySpy).not.toHaveBeenCalled(); }); - it('does not paginate newer (query) when the client is disconnected', async () => { - let loadMoreNewer: ChannelActionContextValue['loadMoreNewer'] | undefined; - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - (c) => { - loadMoreNewer = c.loadMoreNewer; - }, - ); - - // loadMoreNewer bails out early unless there is a newer page to fetch - channel.state.messagePagination.hasNext = true; - - const querySpy = vi.spyOn(channel, 'query'); - channel.disconnected = true; - - await act(async () => { - await loadMoreNewer?.(); - }); - - expect(querySpy).not.toHaveBeenCalled(); - }); - it('does not throw during render when the channel is disconnected (#3254)', async () => { // initClient stubs getConfig; restore it so the disconnect guard is reachable vi.mocked(channel.getConfig).mockRestore(); From eae6c2d5c7356726c360a7083144ae99a98ec77e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 6 Aug 2026 11:26:29 +0200 Subject: [PATCH 5/5] test(Channel): restore loadMoreNewer disconnect coverage Fold the loadMoreNewer assertion into the existing disconnected-pagination test instead of adding a separate one. The extra test entry shifted parallel test scheduling enough to tip a pre-existing race in the VirtualizedMessageList empty-list snapshot on CI, where react-virtuoso reported "not at bottom" and rendered the jump-to-latest button. --- src/components/Channel/__tests__/Channel.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index d0d53c289..54b569047 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -810,18 +810,24 @@ describe('Channel', () => { it('does not paginate (query) when the client is disconnected', async () => { let loadMore: ChannelActionContextValue['loadMore'] | undefined; + let loadMoreNewer: ChannelActionContextValue['loadMoreNewer'] | undefined; await renderComponent( { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, (c) => { loadMore = c.loadMore; + loadMoreNewer = c.loadMoreNewer; }, ); + // loadMoreNewer bails out early unless there is a newer page to fetch + channel.state.messagePagination.hasNext = true; + const querySpy = vi.spyOn(channel, 'query'); channel.disconnected = true; await act(async () => { await loadMore?.(); + await loadMoreNewer?.(); }); expect(querySpy).not.toHaveBeenCalled();