import React from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { StreamChat } from 'stream-chat';
import { ChannelPaginator } from 'stream-chat';
import { Chat } from '../../Chat';
import { ChannelList } from '../ChannelList';
import { ThemeScopeProvider } from '../../../context/ThemeScopeContext';
import { WithComponents } from '../../../context/WithComponents';
import type { ListLoadErrorIndicatorProps } from '../../Loading';
import { getTestClientWithUser } from '../../../mock-builders';
import { axe } from '../../../../axe-helper';
const setupClient = async () => {
const client = await getTestClientWithUser({ id: 'user_x' });
const queryChannels = vi
.spyOn(client, 'queryChannelsAndHydrate')
.mockResolvedValue({ channels: [] } as never);
return { client, queryChannels };
};
const renderList = (client: StreamChat, paginator: ChannelPaginator) => {
client.channelManager.insertPaginator({ paginator });
return render(
,
);
};
describe('ChannelList', () => {
afterEach(cleanup);
it('brings a layout-neutral theme scope when rendered on its own', async () => {
const { client } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
paginator.setItems({ isLastPage: true, valueOrFactory: [] });
const { container } = renderList(client, paginator);
const scope = container.firstElementChild;
expect(scope).toHaveClass('str-chat', 'messaging', 'light', 'str-chat__theme-scope');
});
it('adds no theme scope inside an existing one', async () => {
const { client } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
paginator.setItems({ isLastPage: true, valueOrFactory: [] });
client.channelManager.insertPaginator({ paginator });
const { container } = render(
,
);
expect(container.querySelector('.str-chat')).not.toBeInTheDocument();
});
it('queries the first page of a list that has never been queried', async () => {
const { client, queryChannels } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
renderList(client, paginator);
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1));
await waitFor(() => expect(paginator.items).toEqual([]));
});
it('does not query a list that already has a loaded page', async () => {
const { client, queryChannels } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
// A loaded but empty page, e.g. a catch-all list seeded so it never auto-queries.
paginator.setItems({ isLastPage: true, valueOrFactory: [] });
renderList(client, paginator);
await waitFor(() => expect(paginator.items).toEqual([]));
expect(queryChannels).not.toHaveBeenCalled();
});
// A live event can route a channel into a list that was never queried (e.g. a message makes a
// channel unread before the "Unread" list was ever shown): `items` is then defined, but no page
// was loaded, so the first query still has to run.
it('queries the first page of a list that a live event reached first', async () => {
const { client, queryChannels } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
paginator.ingestItem(client.channel('messaging', 'ingested'));
expect(paginator.items).toHaveLength(1);
renderList(client, paginator);
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1));
});
it('queries the first page of a list that a live event reached and left again', async () => {
const { client, queryChannels } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
const channel = client.channel('messaging', 'ingested');
paginator.ingestItem(channel);
paginator.removeItem({ item: channel });
expect(paginator.items).toEqual([]);
renderList(client, paginator);
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1));
});
it('does not query a seeded list that a live event reached', async () => {
const { client, queryChannels } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
paginator.setItems({ isLastPage: true, valueOrFactory: [] });
paginator.ingestItem(client.channel('messaging', 'ingested'));
renderList(client, paginator);
await waitFor(() => expect(paginator.items).toHaveLength(1));
expect(queryChannels).not.toHaveBeenCalled();
});
it('re-queries when the list data is discarded while it stays mounted', async () => {
const { client, queryChannels } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
renderList(client, paginator);
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1));
// Mirrors `client.disconnectUser()`, which resets every registered list. The paginator outlives
// the component, so a mount-only query would leave the list empty forever.
act(() => {
client.channelManager.resetPaginatorStates();
});
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(2));
await waitFor(() => expect(paginator.items).toEqual([]));
});
describe('when the first page fails to load', () => {
// No retries inside the paginator, so a single rejected request fails the query.
const createFailingList = async () => {
const { client, queryChannels } = await setupClient();
queryChannels.mockRejectedValueOnce(new Error('query failed'));
const paginator = new ChannelPaginator({
client,
id: 'channels:a',
paginatorOptions: { retryCount: 0 },
});
return { client, paginator, queryChannels };
};
it('shows the error with a retry that loads the list', async () => {
const { client, paginator, queryChannels } = await createFailingList();
renderList(client, paginator);
expect(await screen.findByText("Couldn't load conversations")).toBeInTheDocument();
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(2));
expect(await screen.findByText('No conversations yet')).toBeInTheDocument();
expect(screen.queryByText("Couldn't load conversations")).not.toBeInTheDocument();
});
it('passes axe checks', async () => {
const { client, paginator } = await createFailingList();
const { container } = renderList(client, paginator);
await screen.findByText("Couldn't load conversations");
expect(await axe(container)).toHaveNoViolations();
});
it('keeps the error and publishes it again when the retry fails too', async () => {
const { client, paginator, queryChannels } = await createFailingList();
queryChannels.mockRejectedValueOnce(new Error('query failed again'));
const addNotification = vi.spyOn(client.notifications, 'add');
renderList(client, paginator);
await screen.findByText("Couldn't load conversations");
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
await waitFor(() => expect(addNotification).toHaveBeenCalledTimes(2));
expect(paginator.lastQueryError?.message).toBe('query failed again');
expect(screen.getByText("Couldn't load conversations")).toBeInTheDocument();
expect(queryChannels).toHaveBeenCalledTimes(2);
});
it('keeps the focused button on screen, busy, while the retry runs and after it fails', async () => {
const { client, paginator, queryChannels } = await createFailingList();
let rejectRetry: (error: Error) => void = () => undefined;
queryChannels.mockImplementationOnce(
() => new Promise((_, reject) => (rejectRetry = reject)) as never,
);
renderList(client, paginator);
const button = await screen.findByRole('button', { name: 'Try again' });
button.focus();
fireEvent.click(button);
await waitFor(() => expect(button).toHaveAttribute('aria-busy', 'true'));
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button).toBeInTheDocument();
expect(document.activeElement).toBe(button);
act(() => rejectRetry(new Error('query failed again')));
await waitFor(() => expect(button).toHaveAttribute('aria-busy', 'false'));
expect(paginator.lastQueryError?.message).toBe('query failed again');
expect(document.activeElement).toBe(button);
});
it('announces a retry that loads the list', async () => {
const { client, paginator } = await createFailingList();
renderList(client, paginator);
fireEvent.click(await screen.findByRole('button', { name: 'Try again' }));
expect(await screen.findByText('No conversations yet')).toBeInTheDocument();
expect(await screen.findByText('Conversations loaded')).toBeInTheDocument();
});
it('announces nothing for a retry that settles after the list unmounted', async () => {
const { client, paginator, queryChannels } = await createFailingList();
let resolveRetry: (value: unknown) => void = () => undefined;
queryChannels.mockImplementationOnce(
() => new Promise((resolve) => (resolveRetry = resolve)) as never,
);
client.channelManager.insertPaginator({ paginator });
const { rerender } = render(
,
);
fireEvent.click(await screen.findByRole('button', { name: 'Try again' }));
// Only the list unmounts; `Chat` and its announcer stay.
rerender({null});
act(() => resolveRetry({ channels: [] }));
await waitFor(() => expect(paginator.items).toEqual([]));
// Longer than any announcement delay.
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
expect(screen.queryByText('Conversations loaded')).not.toBeInTheDocument();
});
it('loads only the first page when connection recovery re-queries the list', async () => {
const { client, paginator, queryChannels } = await createFailingList();
const channel = client.channel('messaging', 'recovered');
queryChannels.mockResolvedValue({ channels: [channel] } as never);
renderList(client, paginator);
await screen.findByText("Couldn't load conversations");
await act(async () => {
await client.channelManager.recover();
});
// Longer than the paginator's query debounce, so a stray debounced query would have run.
await act(() => new Promise((resolve) => setTimeout(resolve, 500)));
expect(paginator.items).toEqual([channel]);
expect(screen.queryByText("Couldn't load conversations")).not.toBeInTheDocument();
expect(queryChannels).toHaveBeenCalledTimes(2);
});
it('renders the ListLoadErrorIndicator override with the error and the retry', async () => {
const { client, paginator, queryChannels } = await createFailingList();
const CustomLoadErrorIndicator = ({
error,
retry,
}: ListLoadErrorIndicatorProps) => (
);
client.channelManager.insertPaginator({ paginator });
render(
,
);
fireEvent.click(
await screen.findByRole('button', { name: 'Custom: query failed' }),
);
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(2));
});
it('queries again when the list is reset after the failure', async () => {
const { client, paginator, queryChannels } = await createFailingList();
renderList(client, paginator);
await screen.findByText("Couldn't load conversations");
expect(queryChannels).toHaveBeenCalledTimes(1);
// Mirrors `client.disconnectUser()`, which resets every registered list and so clears the
// error the failed query left.
act(() => {
client.channelManager.resetPaginatorStates();
});
await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(2));
await waitFor(() => expect(paginator.items).toEqual([]));
});
it('publishes one error notification for the channel list panel', async () => {
const { client, paginator } = await createFailingList();
const addNotification = vi.spyOn(client.notifications, 'add');
const { unmount } = renderList(client, paginator);
await screen.findByText("Couldn't load conversations");
expect(addNotification).toHaveBeenCalledTimes(1);
expect(addNotification).toHaveBeenCalledWith(
expect.objectContaining({
message: "Couldn't load conversations",
options: expect.objectContaining({
originalError: paginator.lastQueryError,
severity: 'error',
tags: ['target:channel-list'],
type: 'api:channel:query:failed',
}),
}),
);
// A remounted list shows the same error without publishing it again.
unmount();
renderList(client, paginator);
await screen.findByText("Couldn't load conversations");
expect(addNotification).toHaveBeenCalledTimes(1);
});
});
});