diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index e24c50d4f3..874366f40f 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.1.12", "react-native-web": "^0.21.2", "react-native-worklets": "0.11.1", - "stream-chat": "^9.50.1", + "stream-chat": "^9.50.3", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index c865c25a56..dfcf3b8415 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.1.12", "react-native-video": "^6.19.2", "react-native-worklets": "^0.11.1", - "stream-chat": "^9.50.1", + "stream-chat": "^9.50.3", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index ee23633dc2..eef0eb4323 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.50.1", + "stream-chat": "^9.50.3", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { 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 }); } }, ); diff --git a/yarn.lock b/yarn.lock index f2e1e3f0f0..3c3d11becd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7029,7 +7029,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-web: "npm:^0.21.2" react-native-worklets: "npm:0.11.1" - stream-chat: "npm:^9.50.1" + stream-chat: "npm:^9.50.3" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18101,7 +18101,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.11.1" - stream-chat: "npm:^9.50.1" + stream-chat: "npm:^9.50.3" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18840,7 +18840,7 @@ __metadata: react-native-worklets: "npm:^0.11.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.50.1" + stream-chat: "npm:^9.50.3" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -18914,9 +18914,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.50.1": - version: 9.50.1 - resolution: "stream-chat@npm:9.50.1" +"stream-chat@npm:^9.50.3": + version: 9.50.3 + resolution: "stream-chat@npm:9.50.3" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -18932,7 +18932,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/a9ce574be496765934b2c46fe8b18a9ab1fe4a3127a3f66a6f7dfd3ea7cf597e2018e9ddf6851ab074119e27239d333d3779c70cc3cef685862d94387b83fbb0 + checksum: 10c0/75be67c6533f6bf02313565eb164115a2b61b399ab268f931689bb709ee75f6a5c68c01baa835082252856b2c307c13c4aa8a6986de20ab0f19cda1f062c7d9b languageName: node linkType: hard