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
2 changes: 1 addition & 1 deletion examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^"
},
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
107 changes: 95 additions & 12 deletions package/src/components/ChannelList/__tests__/ChannelList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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: (() => void | Promise<void>) | undefined;
const RefreshListProbe = () => {
const { refreshing, refreshList } = useChannelsContext();
capturedRefreshList = refreshList;
return <Text testID='refreshing'>{`${refreshing}`}</Text>;
};

const ChannelPreviewContent = ({ unread }: { unread?: number }) => (
<Text testID='preview-unread'>{`${unread}`}</Text>
);
Expand Down Expand Up @@ -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(
<Chat client={chatClient}>
Expand All @@ -980,27 +995,95 @@ describe('ChannelList', () => {
</Chat>,
);

// 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(
<Chat client={chatClient}>
<WithComponents overrides={{ ChannelPreview: RefreshListProbe }}>
<ChannelList {...props} />
</WithComponents>
</Chat>,
);

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();
});
});
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 @@ -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;
}

Expand Down Expand Up @@ -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 });
}
},
);
Expand Down
14 changes: 7 additions & 7 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -18932,7 +18932,7 @@ __metadata:
built: true
husky:
built: true
checksum: 10c0/a9ce574be496765934b2c46fe8b18a9ab1fe4a3127a3f66a6f7dfd3ea7cf597e2018e9ddf6851ab074119e27239d333d3779c70cc3cef685862d94387b83fbb0
checksum: 10c0/75be67c6533f6bf02313565eb164115a2b61b399ab268f931689bb709ee75f6a5c68c01baa835082252856b2c307c13c4aa8a6986de20ab0f19cda1f062c7d9b
languageName: node
linkType: hard

Expand Down
Loading