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