Skip to content
Open
1 change: 1 addition & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/components/Channel/__tests__/Channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ChatProvider, useChatContext } from '../../../context/ChatContext';
import { useComponentContext } from '../../../context/ComponentContext';
import {
dispatchChannelTruncatedEvent,
dispatchConnectionChangedEvent,
generateChannel,
generateFileAttachment,
generateMember,
Expand Down Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
91 changes: 91 additions & 0 deletions src/components/ChannelList/__tests__/ChannelList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,56 @@
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(
<Chat client={chatClient}>
<WithComponents
overrides={{
ChannelListItemUI: ChannelPreviewComponent,
ChannelListUI: ChannelListComponent,
}}
>
<ChannelList
allowNewMessagesFromUnfilteredChannels={false}
filters={{}}
options={{
limit: 25,
message_limit: 25,
presence: true,
state: true,
watch: true,
}}
/>
</WithComponents>
</Chat>,
);

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', () => {
Expand Down Expand Up @@ -1419,6 +1469,47 @@
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(
<Chat client={chatClient}>
<WithComponents
overrides={{
ChannelListItemUI: ChannelPreviewComponent,
ChannelListUI: ChannelListComponent,
}}
>
<ChannelList {...channelListProps} />
</WithComponents>
</Chat>,
);
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();

Check failure on line 1511 in src/components/ChannelList/__tests__/ChannelList.test.tsx

View workflow job for this annotation

GitHub Actions / Test

src/components/ChannelList/__tests__/ChannelList.test.tsx > ChannelList > Event handling > notification.removed_from_channel > evicts the removed channel from client.activeChannels so reconnect cannot re-watch it (#2599)

AssertionError: expected Channel{ …(20) } to be undefined - Expected: undefined + Received: Channel { "_callChannelListeners": [Function anonymous], "_channelURL": [Function anonymous], "_client": _StreamChat { "_buildWSPayload": [Function anonymous], "_cacheEnabled": [Function anonymous], "_callClientListeners": [Function anonymous], "_deleteUserMessageReference": [Function anonymous], "_getConnectionID": [Function anonymous], "_handleUserEvent": [Function anonymous], "_hasConnectionID": [Function anonymous], "_isUsingServerAuth": [Function anonymous], "_messageComposerSetupState": StateStore { "handlers": Set {}, "partialNext": [Function anonymous], "preprocessors": Set {}, "subscribeWithSelector": [Function anonymous], "value": { "setupFunction": null, }, }, "_normalizeDate": [Function anonymous], "_setToken": [Function Mock], "_setupConnection": [Function Mock], "_unblockMessage": [Function unblockMessage], "_updateMemberWatcherReferences": [Function anonymous], "_updateUserMessageReferences": [Function anonymous], "_updateUserReferences": [Function anonymous], "_user": { "id": "uthred", }, "activeChannels": { "messaging:44uyapEpPqm988fyplDOm": Channel { "_callChannelListeners": [Function anonymous], "_channelURL": [Function anonymous], "_client": [Circular], "_data": {}, "cid": "messaging:44uyapEpPqm988fyplDOm", "cooldownTimer": CooldownTimer { "channel": [Circular], "clearTimeout": [Function anonymous], "recalculate": [Function anonymous], "refCount": 0, "refresh": [Function anonymous], "registerSubscriptions": [Function anonymous], "setCooldownRemaining": [Function anonymous], "setOwnLatestMessageDate": [Function anonymous], "state": StateStore { "handlers": Set {}, "partialNext": [Function anonymous], "preprocessors": Set {}, "subscribeWithSelector": [Function anonymous], "value": { "canSkipCooldown": false, "cooldownConfigSeconds": 0, "cooldownRemaining": 0, "ownLatestMessageDate": undefined, }, }, "timeout": null, "unsubscribeFunctions": Set {}, }, "create": [Function anonymous], "data": { "cid": "messaging:44uyapEpPqm988fyplDOm", "config": { "automod": "disabled", "automod_behavior": "flag", "commands": [ { "args": "[text]", "description": "Post a random gif to the channel", "name": "giphy", "set": "fun_set", }, ], "connect_events": true, "created_at": "2020-04-24T11:36:43.859020368Z", "max_message_length": 5000, "message_retention": "infinite", "mutes": true, "name": "messaging", "polls": true, "reactions": true, "read_events": true, "replies": true, "search": true, "shared_locations": true, "typing_events": true, "updated_at": "2020-04-24T11:36:43.859022903Z", "uploads": true, "url_enrichment": true, }, "created_at": "2020-04-28T11:20:48.578147Z", "created_by": { "banned": false, "created_at": "2020-04-27T13:05:13.847572Z", "id": "vishal", "last_active": "2020-04-28T11:21:08.353026Z", "online": false, "role": "user", "updated_at": "2020-04-28T11:21:08.357468Z", }, "disabled": false, "frozen": false, "id": "44uyapEpPqm988fyplDOm", "type": "messaging", "updated_at": "2020-04-28T11:20:48.578147Z", }, "disconnected": false,
});
});

describe('channel.updated', () => {
Expand Down
13 changes: 9 additions & 4 deletions src/components/ChannelList/hooks/useChannelListShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -174,10 +183,6 @@ export const useChannelListShapeDefaults = () => {
return;
}

if (!allowNewMessagesFromUnfilteredChannels) {
return;
}

setChannels((channels) =>
moveChannelUpwards({
channels,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<ChatContext.Provider value={fromPartial<ChatContextValue>({ client })}>
{children}
</ChatContext.Provider>
);
};

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);
});
});
27 changes: 21 additions & 6 deletions src/components/ChannelListItem/hooks/useIsChannelMuted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
oliverlaz marked this conversation as resolved.

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;
};
Loading