From e8c917c05a0567a9b0d3f60e5e7c5e9763d948b4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:46:00 +0200 Subject: [PATCH 1/2] fix: recover db gracefully on errors (#3764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR bumps the LLC to its latest version in order to fix this [GH issue](https://github.com/GetStream/stream-chat-js/issues/1816). ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/package.json | 2 +- package/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) 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/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 From 7f08741adf7306a9ec40188b9f146d32fe49688a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:51:23 +0200 Subject: [PATCH 2/2] fix: reconnection debounce refreshing issues (#3765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR fixes a very old issue that seems to have been dormant in the SDK for quite a while now. Since we have a debouncing mechanism for refreshing the channel list (i.e so that we can prevent users doing pull to refresh too often), this inadvertently affects our reconnection flow as well unfortunately. Let us consider the following scenario: - Our connection flickers and the WS is killed (channels are unwatched) - We regain connection briefly (`refreshList` runs) - We briefly lose connection again (channels are unwatched again) - We regain connection again The second `refreshList()` will **NOT** run, because of the debounce timer. This means that the channels will remain unwatched until we either receive a `notification.X` event or another hard refresh happens past the debounce timer. The same can be reproduced in many other ways (i.e going to background and foreground rapidly, using pull to refresh and then losing connection etc). This was initially noticed while working on V10 but I decided to port it to V9 as well. A V8 port will also come soon after. ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .../__tests__/ChannelList.test.tsx | 107 ++++++++++++++++-- .../ChannelList/hooks/usePaginatedChannels.ts | 17 ++- 2 files changed, 106 insertions(+), 18 deletions(-) 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 }); } }, );