diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index 95cc1da263..d5e094f4e8 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -75,6 +75,17 @@ const RefreshingProbe = () => { return {`${refreshing}`}; }; +/** + * Probe that captures the context `refreshList` (the public, non-forced pull-to-refresh handler) so a + * test can invoke it directly. + */ +let capturedRefreshList: (() => void | Promise) | undefined; +const RefreshListProbe = () => { + const { refreshing, refreshList } = useChannelsContext(); + capturedRefreshList = refreshList; + return {`${refreshing}`}; +}; + const ChannelPreviewContent = ({ unread }: { unread?: number }) => ( {`${unread}`} ); @@ -965,12 +976,16 @@ 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( @@ -980,27 +995,95 @@ describe('ChannelList', () => { , ); + // The probe only renders once the mount query populates the list. await waitFor(() => { expect(screen.getByTestId('refreshing').children[0]).toBe('false'); }); - chatClient.queryChannels = jest.fn( - () => deferredPromise.promise, - ) as typeof chatClient.queryChannels; + // Advance the clock 6s past mount so both reconnects observe t=6000. + dateNowSpy.mockReturnValue(6000); + + const channelManager = createChannelManagerSpy.mock.results[0]?.value as ReturnType< + typeof chatClient.createChannelManager + >; + // 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(chatClient.queryChannels).toHaveBeenCalled(); + 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(channelManager.state.getLatestValue().pagination.isLoading).toBe(false); }); + // 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(chatClient.queryChannels).toHaveBeenCalledTimes(1); + // Background reconnection refreshes never surface in the pull-to-refresh UI. expect(screen.getByTestId('refreshing').children[0]).toBe('false'); - deferredPromise.resolve([testChannel1]); + 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( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('refreshing').children[0]).toBe('false'); + }); + + const channelManager = createChannelManagerSpy.mock.results[0]?.value as ReturnType< + typeof chatClient.createChannelManager + >; + 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 () => { + await capturedRefreshList?.(); + }); + expect(querySpy).toHaveBeenCalledTimes(1); + dateNowSpy.mockRestore(); }); }); diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index 3f8603337d..746a687d2e 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -131,10 +131,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; } @@ -170,9 +174,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 }); } }, );