diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 9f7c4b9a8..9d031d41c 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,10 @@ const ChannelInner = ( ); const handleEvent = async (event: Event) => { + // client-level subscriptions keep firing after disconnect, and reading from + // or querying a disconnected channel throws + if (channel.disconnected) return; + if (event.message) { dispatch({ channel, @@ -660,6 +665,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 862cd8f73..54b569047 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, @@ -807,18 +810,99 @@ 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(); + }); + + 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(); + + 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()); + + channel.disconnected = true; + + // changing a Channel prop bypasses React.memo and re-renders ChannelInner + expect(() => + act(() => { + setGiphyVersion('original'); + }), + ).not.toThrow(); + + 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 () => { + // must be initialized, otherwise Channel queries on mount and errors 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; + + await act(async () => { + chatClient.dispatchEvent(fromPartial({ type: 'user.deleted' })); + await Promise.resolve(); }); expect(querySpy).not.toHaveBeenCalled(); 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..165239fe0 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -95,6 +95,10 @@ const MessageComposerProvider = (props: PropsWithChildren) useEffect( () => () => { + // both createDraft() and clear() reach channel.getConfig(), which throws + // for a disconnected channel + 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..4032d66f7 100644 --- a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx +++ b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx @@ -774,6 +774,25 @@ 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 getConfig; restore it so the guard 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 + 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..26ce777ca 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx @@ -116,4 +116,15 @@ describe('useMessageComposerCommands', () => { { command: expect.objectContaining({ name: 'ban' }), enabled: false }, ]); }); + it('returns no commands for a disconnected channel without calling getConfig (#3254)', () => { + 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..6f7a8209d 100644 --- a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx @@ -834,4 +834,24 @@ describe('useMarkRead', () => { }); }); }); + + it('does not throw when the channel is disconnected (#3254)', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + // initClientWithChannels stubs getConfig; restore it so the guard 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..bcffb20b2 --- /dev/null +++ b/src/utils/__tests__/getChannelConfig.test.ts @@ -0,0 +1,27 @@ +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', () => { + 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..6a319c1bb --- /dev/null +++ b/src/utils/getChannelConfig.ts @@ -0,0 +1,9 @@ +import type { Channel, ChannelConfigWithInfo } from 'stream-chat'; + +/** + * `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();