Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 87 additions & 20 deletions package/src/components/ChannelList/__tests__/ChannelList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ const RefreshingProbe = () => {
return <Text testID='refreshing'>{`${refreshing}`}</Text>;
};

/**
* Probe that captures the context `refreshList` (the public, non-forced pull-to-refresh handler) so a
* test can invoke it directly.
*/
let capturedRefreshList;
const RefreshListProbe = () => {
const { refreshing, refreshList } = useChannelsContext();
capturedRefreshList = refreshList;
return <Text testID='refreshing'>{`${refreshing}`}</Text>;
};

class DeferredPromise {
constructor() {
this.promise = new Promise((resolve, reject) => {
Expand Down Expand Up @@ -677,49 +688,105 @@ describe('ChannelList', () => {
});

describe('connection.changed', () => {
it('should keep background reconnection refreshes debounced and out of pull-to-refresh UI', async () => {
it('should force reconnection refreshes past the pull-to-refresh debounce while keeping them out of the refreshing UI', async () => {
// Regression guard: a reconnect is the sole trigger that re-watches channels on the fresh
// socket, so it must bypass the 5s pull-to-refresh throttle (`force`). Without the bypass a
// second reconnect landing inside the debounce window is dropped and its channels stay
// un-watched (frozen last message / unread) until the next reconnect > 5s later.
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
const deferredPromise = new DeferredPromise();
const dateNowSpy = jest.spyOn(Date, 'now');
dateNowSpy.mockReturnValueOnce(0);
dateNowSpy.mockReturnValue(6000);
const createChannelManagerSpy = jest.spyOn(chatClient, 'createChannelManager');
// Freeze the clock at t=0 for the whole mount so `lastRefresh` is seeded to 0 regardless of
// how many `Date.now()` calls the render makes.
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(0);

render(
<Chat client={chatClient}>
<ChannelList {...props} List={RefreshingProbe} />
</Chat>,
);

// The probe only renders once the mount query populates the list.
await waitFor(() => {
expect(screen.getByTestId('refreshing').children[0]).toBe('false');
});

const queryChannelsSpy = jest
.spyOn(chatClient, 'queryChannels')
.mockImplementation(() => deferredPromise.promise);
// Advance the clock 6s past mount so both reconnects observe t=6000.
dateNowSpy.mockReturnValue(6000);

await act(async () => {
dispatchConnectionChangedEvent(chatClient, false);
dispatchConnectionChangedEvent(chatClient, true);
await Promise.resolve();
});
const channelManager = createChannelManagerSpy.mock.results[0]?.value;
// Spy (not replace) so reconnect queries still hydrate through the mocked axios response and
// keep the list — and therefore the refreshing probe — mounted.
const querySpy = jest.spyOn(chatClient, 'queryChannels');

// Reconnect #1 at t=6000, i.e. 6s after mount → outside the debounce window.
act(() => dispatchConnectionChangedEvent(chatClient, false));
act(() => dispatchConnectionChangedEvent(chatClient, true));
await waitFor(() => {
expect(querySpy).toHaveBeenCalledTimes(1);
});
// Let query #1 settle so the ChannelManager's in-flight guard (isLoading) clears; otherwise it,
// not the debounce, would be what drops the second query.
await waitFor(() => {
expect(queryChannelsSpy).toHaveBeenCalledTimes(1);
expect(channelManager.state.getLatestValue().pagination.isLoading).toBe(false);
});

await act(async () => {
dispatchConnectionChangedEvent(chatClient, true);
await Promise.resolve();
// Reconnect #2 at t=6000, i.e. 0ms after reconnect #1 → inside the debounce window. It fires a
// fresh query only because reconnection refreshes are forced past the throttle.
act(() => dispatchConnectionChangedEvent(chatClient, false));
act(() => dispatchConnectionChangedEvent(chatClient, true));
await waitFor(() => {
expect(querySpy).toHaveBeenCalledTimes(2);
});

expect(queryChannelsSpy).toHaveBeenCalledTimes(1);
// Background reconnection refreshes never surface in the pull-to-refresh UI.
expect(screen.getByTestId('refreshing').children[0]).toBe('false');

await waitFor(() => {
expect(channelManager.state.getLatestValue().pagination.isLoading).toBe(false);
});
dateNowSpy.mockRestore();
});
});

describe('refreshList (pull-to-refresh)', () => {
it('should throttle a non-forced refresh that lands within the retry interval', async () => {
// Counterpart to the forced reconnect above: the public `refreshList` is NOT forced, so its
// 5s debounce must still hold — a second pull within the window of the last successful refresh
// is a no-op and fires no query.
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
const createChannelManagerSpy = jest.spyOn(chatClient, 'createChannelManager');
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(0); // mount seeds `lastRefresh` to 0

render(
<Chat client={chatClient}>
<ChannelList {...props} List={RefreshListProbe} />
</Chat>,
);

await waitFor(() => {
expect(screen.getByTestId('refreshing').children[0]).toBe('false');
});

const channelManager = createChannelManagerSpy.mock.results[0]?.value;
const querySpy = jest.spyOn(chatClient, 'queryChannels');

// First pull at t=6000 (6s after mount → outside the window) fires a query.
dateNowSpy.mockReturnValue(6000);
await act(async () => {
await capturedRefreshList?.();
});
await waitFor(() => {
expect(querySpy).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(channelManager.state.getLatestValue().pagination.isLoading).toBe(false);
});

// Second pull at t=6000 (0ms later → inside the window) is throttled: no additional query.
await act(async () => {
deferredPromise.resolve([generateChannel({ id: testChannel1.channel.id })]);
await deferredPromise.promise;
await capturedRefreshList?.();
});
expect(querySpy).toHaveBeenCalledTimes(1);

dateNowSpy.mockRestore();
});
Expand Down
17 changes: 11 additions & 6 deletions package/src/components/ChannelList/hooks/usePaginatedChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,14 @@ export const usePaginatedChannels = ({
setActiveQueryType(null);
};

const refreshList = async ({ isBackground = false }: { isBackground?: boolean } = {}) => {
const refreshList = async ({
force = false,
isBackground = false,
}: { force?: boolean; isBackground?: boolean } = {}) => {
const now = Date.now();
// Only allow pull-to-refresh 5 seconds after last successful refresh.
if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) {
// Only allow pull-to-refresh 5 seconds after last successful refresh, unless the request
// is invoked with force: true.
if (!force && now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) {
return;
}

Expand Down Expand Up @@ -168,9 +172,10 @@ export const usePaginatedChannels = ({
'connection.changed',
async (event) => {
if (event.online) {
// Reconnection refreshes should stay silent, but still share the same debounce
// path as pull-to-refresh.
await refreshList({ isBackground: true });
// Reconnection refreshes stay silent but must NOT be throttled by the
// pull-to-refresh debounce. This is the query that rewatches the
// channels on the fresh socket.
await refreshList({ force: true, isBackground: true });
}
},
);
Expand Down
Loading