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