From 2c66e7cb8be87f1bd6f75f2af07c89a6fe0c80cb Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 28 Oct 2026 22:12:27 +0100 Subject: [PATCH 1/6] fix: read message_limit messages per channel from the offline DB --- .../selectMessagesForChannels.test.ts | 63 +++++++++++++++++++ package/src/store/apis/getChannelMessages.ts | 5 +- package/src/store/apis/getChannels.ts | 6 +- .../store/apis/getChannelsForFilterSort.ts | 1 + .../apis/queries/selectMessagesForChannels.ts | 9 ++- 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 package/src/store/__tests__/selectMessagesForChannels.test.ts diff --git a/package/src/store/__tests__/selectMessagesForChannels.test.ts b/package/src/store/__tests__/selectMessagesForChannels.test.ts new file mode 100644 index 0000000000..1d7ac87b76 --- /dev/null +++ b/package/src/store/__tests__/selectMessagesForChannels.test.ts @@ -0,0 +1,63 @@ +import type { ChannelAPIResponse } from 'stream-chat'; + +import { generateChannelResponse } from '../../mock-builders/generator/channel'; +import { generateMessage } from '../../mock-builders/generator/message'; +import { BetterSqlite } from '../../test-utils/BetterSqlite'; +import { getChannels } from '../apis/getChannels'; +import { selectMessagesForChannels } from '../apis/queries/selectMessagesForChannels'; +import { upsertChannels } from '../apis/upsertChannels'; +import { SqliteClient } from '../SqliteClient'; + +/** + * The client reads the cached page as a channel list page of `message_limit` messages, so a page of + * a different size makes it guess wrongly whether older messages exist, for example that a channel + * with a long history starts at its cached messages. + */ +describe('selectMessagesForChannels', () => { + let cid: string; + let messages: ReturnType[]; + + beforeEach(async () => { + await SqliteClient.initializeDatabase(); + await BetterSqlite.openDB(); + + const channelResponse = generateChannelResponse({ members: [], messages: [] }); + cid = channelResponse.channel.cid as string; + messages = Array.from({ length: 30 }, (_, i) => + generateMessage({ cid, timestamp: new Date(Date.UTC(2020, 0, 1, 0, i)) }), + ); + channelResponse.messages = messages as unknown as typeof channelResponse.messages; + await upsertChannels({ + channels: [channelResponse] as unknown as ChannelAPIResponse[], + isLatestMessagesSet: true, + }); + }); + + afterEach(() => { + BetterSqlite.dropAllTables(); + BetterSqlite.closeDB(); + }); + + const ids = (rows: { id: string }[]) => rows.map((row) => row.id); + + it("returns a channel's newest 25 messages, oldest first, by default", async () => { + const rows = await selectMessagesForChannels([cid]); + + expect(ids(rows)).toEqual(ids(messages.slice(-25))); + }); + + it("returns a channel's newest `messageLimit` messages", async () => { + expect(ids(await selectMessagesForChannels([cid], 10))).toEqual(ids(messages.slice(-10))); + expect(ids(await selectMessagesForChannels([cid], 50))).toEqual(ids(messages)); + }); + + it('applies the limit that getChannels is given', async () => { + const [channel] = await getChannels({ + channelIds: [cid], + currentUserId: 'some-user', + messageLimit: 10, + }); + + expect(ids(channel.messages)).toEqual(ids(messages.slice(-10))); + }); +}); diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 87ae9641ed..1b7fed52fb 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -12,15 +12,18 @@ import type { TableRow, TableRowJoinedUser } from '../types'; export const getChannelMessages = async ({ channelIds, currentUserId, + messageLimit, }: { channelIds: string[]; currentUserId: string; + messageLimit?: number; }) => { SqliteClient.logger?.('info', 'getChannelMessages', { channelIds, currentUserId, + messageLimit, }); - const messageRows = await selectMessagesForChannels(channelIds); + const messageRows = await selectMessagesForChannels(channelIds, messageLimit); const messageIds = messageRows.map(({ id }) => id); // Populate the message reactions. diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index 094981c2aa..c7f9e89743 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -16,17 +16,20 @@ import { SqliteClient } from '../SqliteClient'; * @param {Object} param * @param {Array} param.channelIds List of channel ids to fetch. * @param {Array} param.currentUserId Id of the current logged in user. + * @param {number} param.messageLimit How many of each channel's newest messages to return, 25 by default. * * @returns {Array} Channels with enriched state. */ export const getChannels = async ({ channelIds, currentUserId, + messageLimit, }: { channelIds: string[]; currentUserId: string; + messageLimit?: number; }): Promise[]> => { - SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); + SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId, messageLimit }); const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages, cidVsActiveLocations] = await Promise.all([ @@ -37,6 +40,7 @@ export const getChannels = async ({ getChannelMessages({ channelIds, currentUserId, + messageLimit, }), getChannelActiveLocations({ channelIds }), ]); diff --git a/package/src/store/apis/getChannelsForFilterSort.ts b/package/src/store/apis/getChannelsForFilterSort.ts index 6ecd35d3b3..f029e68037 100644 --- a/package/src/store/apis/getChannelsForFilterSort.ts +++ b/package/src/store/apis/getChannelsForFilterSort.ts @@ -48,5 +48,6 @@ export const getChannelsForFilterSort = async ({ return await getChannels({ channelIds, currentUserId, + messageLimit: options?.message_limit, }); }; diff --git a/package/src/store/apis/queries/selectMessagesForChannels.ts b/package/src/store/apis/queries/selectMessagesForChannels.ts index c7c62074f5..c6257bf039 100644 --- a/package/src/store/apis/queries/selectMessagesForChannels.ts +++ b/package/src/store/apis/queries/selectMessagesForChannels.ts @@ -2,8 +2,12 @@ import { tables } from '../../schema'; import { SqliteClient } from '../../SqliteClient'; import type { TableRowJoinedUser } from '../../types'; +/** The `message_limit` the client assumes for a channel list query that sets none. */ +export const DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT = 25; + export const selectMessagesForChannels = async ( cids: string[], + messageLimit: number = DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT, ): Promise[]> => { const questionMarks = Array(cids.length).fill('?').join(','); const messagesColumnNames = Object.keys(tables.messages.columns) @@ -15,6 +19,7 @@ export const selectMessagesForChannels = async ( SqliteClient.logger?.('info', 'selectMessagesForChannels', { cids, + messageLimit, }); const result = await SqliteClient.executeSql( @@ -38,9 +43,9 @@ export const selectMessagesForChannels = async ( LEFT JOIN users b ON b.id = a.userId - WHERE RowNum < 25 + WHERE RowNum <= ? ORDER BY a.createdAt ASC`, - cids, + [...cids, messageLimit], ); return result.map((r) => JSON.parse(r.value)); From 03f7e17a534fa1e1cab0f916972b340cd2a02a32 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 28 Oct 2026 22:19:45 +0100 Subject: [PATCH 2/6] fix: retry message list pagination after a load that loaded nothing --- .../MessageList/MessageFlashList.tsx | 39 +++++++++++--- .../components/MessageList/MessageList.tsx | 27 ++++++++-- .../__tests__/MessageList.test.tsx | 51 +++++++++++++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index f3658c10b2..fe662e8256 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -147,7 +147,7 @@ type MessageFlashListPropsWithContext = Pick< > & Pick< ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' + 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadHasMore' | 'threadInstance' > & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want @@ -346,6 +346,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => hasPendingInitialTargetLoad, targetedMessage, thread, + threadHasMore, threadInstance, threadList = false, } = props; @@ -835,6 +836,16 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * once per content length. */ + /** + * How many messages the list pages through are loaded: the thread's replies in a thread list, the + * channel's messages otherwise. + */ + const getLoadedMessageCount = useStableCallback(() => + threadList && thread?.id + ? (channel?.state.threads[thread.id]?.length ?? 0) + : (channel?.state.messages.length ?? 0), + ); + /** * 1. Makes a call to `loadMore` function, which queries more older messages. * 2. Ensures that we call `loadMore`, once per content length @@ -853,8 +864,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onStartReachedInPromise.current = null; + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -869,12 +883,13 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -885,7 +900,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => */ const maybeCallOnEndReached = useStableCallback(async () => { // If onStartReached has already been called for given data length, then ignore. - if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) { + if ( + (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) || + (threadList && !threadHasMore) + ) { return; } @@ -893,9 +911,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onEndReachedInPromise.current = null; - + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -911,8 +931,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onStartReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -1313,7 +1334,8 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); - const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); + const { loadMoreRecentThread, loadMoreThread, thread, threadHasMore, threadInstance } = + useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); @@ -1355,6 +1377,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { shouldShowUnreadUnderlay, targetedMessage, thread, + threadHasMore, threadInstance, threadList, }} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index baf66fb98c..d2d9269cf6 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -932,6 +932,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * once per content length. */ + /** + * How many messages the list pages through are loaded: the thread's replies in a thread list, the + * channel's messages otherwise. + */ + const getLoadedMessageCount = useStableCallback(() => + threadList && thread?.id + ? (channel?.state.threads[thread.id]?.length ?? 0) + : (channel?.state.messages.length ?? 0), + ); + /** * 1. Makes a call to `loadMoreRecent` function, which queries more recent messages. * 2. Ensures that we call `loadMoreRecent`, once per content length @@ -950,9 +960,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onStartReachedInPromise.current = null; - + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -967,12 +979,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -995,8 +1008,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onEndReachedInPromise.current = null; + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -1011,8 +1027,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onStartReachedInPromise.current) { await onStartReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index bfdee3cb8d..438140fad7 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -650,6 +650,57 @@ describe('MessageList pagination', () => { }); }); + it('tries loading older messages again after a load that loaded nothing', async () => { + const user1 = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages: Array.from({ length: 100 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + // resolves without loading anything, as `loadMore` does when its request fails, e.g. offline + const loadMore = jest.fn(() => Promise.resolve()); + mockedHook({ loadMore }); + + const { getByTestId } = render( + + + + + + + , + ); + + const scrollToEnd = () => + act(() => { + fireEvent(getByTestId('message-flat-list'), 'momentumScrollEnd', { + nativeEvent: { + contentOffset: { y: 1900 }, + contentSize: { height: 2000, width: 200 }, + layoutMeasurement: { height: 400, width: 200 }, + }, + }); + }); + + scrollToEnd(); + await waitFor(() => { + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + // released after the same 2 second backoff as a rejected load + await act(() => new Promise((resolve) => setTimeout(resolve, 2100))); + scrollToEnd(); + + await waitFor(() => { + expect(loadMore).toHaveBeenCalledTimes(2); + }); + }); + it('should call load latest messages when the scroll to bottom button is pressed', async () => { const user1 = generateUser(); const messages = Array.from({ length: 10 }, (_, i) => From 54c967a97e142ea5821d2d511414d0cdf0216cc5 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 28 Oct 2026 22:25:13 +0100 Subject: [PATCH 3/6] fix: load older messages in MessageFlashList until the list fills the screen --- .../MessageList/MessageFlashList.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index fe662e8256..80a1a89edb 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -4,6 +4,7 @@ import { LayoutChangeEvent, ScrollViewProps, StyleSheet, View, useColorScheme } import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; +import debounce from 'lodash/debounce'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -1121,6 +1122,31 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => currentListHeightRef.current = height; }); + const debouncedPrefillMessages = useMemo( + () => + debounce( + (viewportHeight: number, contentHeight: number) => { + if (viewportHeight >= contentHeight) { + maybeCallOnEndReached(); + } + }, + 500, + { + leading: false, + trailing: true, + }, + ), + [maybeCallOnEndReached], + ); + + const onContentSizeChange = useStableCallback((width: number, height: number) => { + if (additionalFlashListProps?.onContentSizeChange) { + additionalFlashListProps.onContentSizeChange(width, height); + } + + debouncedPrefillMessages(currentListHeightRef.current ?? 0, height); + }); + const ListFooterComponent = useCallback(() => { if (FooterComponent) { return ; @@ -1193,6 +1219,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {...additionalFlashListPropsExcludingStyle} accessibilityActions={messageListAccessibilityActions} onAccessibilityAction={messageListOnAccessibilityAction} + onContentSizeChange={onContentSizeChange} /> )} From cb078c72218660b13d1ea098eb31eb54f0b1acf1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 28 Oct 2026 22:37:09 +0100 Subject: [PATCH 4/6] revert: offline message_limit and pagination fixes pushed to develop by mistake Reverts 54c967a97, 03f7e17a5 and 2c66e7cb8, which were pushed without a review. They come back through a pull request. Co-Authored-By: Claude Opus 5.5 --- .../MessageList/MessageFlashList.tsx | 66 +++---------------- .../components/MessageList/MessageList.tsx | 27 ++------ .../__tests__/MessageList.test.tsx | 51 -------------- .../selectMessagesForChannels.test.ts | 63 ------------------ package/src/store/apis/getChannelMessages.ts | 5 +- package/src/store/apis/getChannels.ts | 6 +- .../store/apis/getChannelsForFilterSort.ts | 1 - .../apis/queries/selectMessagesForChannels.ts | 9 +-- 8 files changed, 17 insertions(+), 211 deletions(-) delete mode 100644 package/src/store/__tests__/selectMessagesForChannels.test.ts diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 80a1a89edb..f3658c10b2 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -4,7 +4,6 @@ import { LayoutChangeEvent, ScrollViewProps, StyleSheet, View, useColorScheme } import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; -import debounce from 'lodash/debounce'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -148,7 +147,7 @@ type MessageFlashListPropsWithContext = Pick< > & Pick< ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadHasMore' | 'threadInstance' + 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' > & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want @@ -347,7 +346,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => hasPendingInitialTargetLoad, targetedMessage, thread, - threadHasMore, threadInstance, threadList = false, } = props; @@ -837,16 +835,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * once per content length. */ - /** - * How many messages the list pages through are loaded: the thread's replies in a thread list, the - * channel's messages otherwise. - */ - const getLoadedMessageCount = useStableCallback(() => - threadList && thread?.id - ? (channel?.state.threads[thread.id]?.length ?? 0) - : (channel?.state.messages.length ?? 0), - ); - /** * 1. Makes a call to `loadMore` function, which queries more older messages. * 2. Ensures that we call `loadMore`, once per content length @@ -865,11 +853,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = (loadedMessageCount: number) => { + const callback = () => { onStartReachedInPromise.current = null; - if (getLoadedMessageCount() === loadedMessageCount) { - onError(); - } return Promise.resolve(); }; @@ -884,13 +869,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } - const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(() => callback(loadedMessageCount)) + .then(callback) .catch(onError); }); @@ -901,10 +885,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => */ const maybeCallOnEndReached = useStableCallback(async () => { // If onStartReached has already been called for given data length, then ignore. - if ( - (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) || - (threadList && !threadHasMore) - ) { + if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) { return; } @@ -912,11 +893,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = (loadedMessageCount: number) => { + const callback = () => { onEndReachedInPromise.current = null; - if (getLoadedMessageCount() === loadedMessageCount) { - onError(); - } + return Promise.resolve(); }; @@ -932,9 +911,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onStartReachedInPromise.current; } - const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(() => callback(loadedMessageCount)) + .then(callback) .catch(onError); }); @@ -1122,31 +1100,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => currentListHeightRef.current = height; }); - const debouncedPrefillMessages = useMemo( - () => - debounce( - (viewportHeight: number, contentHeight: number) => { - if (viewportHeight >= contentHeight) { - maybeCallOnEndReached(); - } - }, - 500, - { - leading: false, - trailing: true, - }, - ), - [maybeCallOnEndReached], - ); - - const onContentSizeChange = useStableCallback((width: number, height: number) => { - if (additionalFlashListProps?.onContentSizeChange) { - additionalFlashListProps.onContentSizeChange(width, height); - } - - debouncedPrefillMessages(currentListHeightRef.current ?? 0, height); - }); - const ListFooterComponent = useCallback(() => { if (FooterComponent) { return ; @@ -1219,7 +1172,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {...additionalFlashListPropsExcludingStyle} accessibilityActions={messageListAccessibilityActions} onAccessibilityAction={messageListOnAccessibilityAction} - onContentSizeChange={onContentSizeChange} /> )} @@ -1361,8 +1313,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); - const { loadMoreRecentThread, loadMoreThread, thread, threadHasMore, threadInstance } = - useThreadContext(); + const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); @@ -1404,7 +1355,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { shouldShowUnreadUnderlay, targetedMessage, thread, - threadHasMore, threadInstance, threadList, }} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index d2d9269cf6..baf66fb98c 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -932,16 +932,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * once per content length. */ - /** - * How many messages the list pages through are loaded: the thread's replies in a thread list, the - * channel's messages otherwise. - */ - const getLoadedMessageCount = useStableCallback(() => - threadList && thread?.id - ? (channel?.state.threads[thread.id]?.length ?? 0) - : (channel?.state.messages.length ?? 0), - ); - /** * 1. Makes a call to `loadMoreRecent` function, which queries more recent messages. * 2. Ensures that we call `loadMoreRecent`, once per content length @@ -960,11 +950,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = (loadedMessageCount: number) => { + const callback = () => { onStartReachedInPromise.current = null; - if (getLoadedMessageCount() === loadedMessageCount) { - onError(); - } + return Promise.resolve(); }; @@ -979,13 +967,12 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } - const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(() => callback(loadedMessageCount)) + .then(callback) .catch(onError); }); @@ -1008,11 +995,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = (loadedMessageCount: number) => { + const callback = () => { onEndReachedInPromise.current = null; - if (getLoadedMessageCount() === loadedMessageCount) { - onError(); - } return Promise.resolve(); }; @@ -1027,9 +1011,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onStartReachedInPromise.current) { await onStartReachedInPromise.current; } - const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(() => callback(loadedMessageCount)) + .then(callback) .catch(onError); }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 438140fad7..bfdee3cb8d 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -650,57 +650,6 @@ describe('MessageList pagination', () => { }); }); - it('tries loading older messages again after a load that loaded nothing', async () => { - const user1 = generateUser(); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: Array.from({ length: 100 }, (_, i) => generateMessage({ text: `message-${i}` })), - }); - - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); - await channel.watch(); - - // resolves without loading anything, as `loadMore` does when its request fails, e.g. offline - const loadMore = jest.fn(() => Promise.resolve()); - mockedHook({ loadMore }); - - const { getByTestId } = render( - - - - - - - , - ); - - const scrollToEnd = () => - act(() => { - fireEvent(getByTestId('message-flat-list'), 'momentumScrollEnd', { - nativeEvent: { - contentOffset: { y: 1900 }, - contentSize: { height: 2000, width: 200 }, - layoutMeasurement: { height: 400, width: 200 }, - }, - }); - }); - - scrollToEnd(); - await waitFor(() => { - expect(loadMore).toHaveBeenCalledTimes(1); - }); - - // released after the same 2 second backoff as a rejected load - await act(() => new Promise((resolve) => setTimeout(resolve, 2100))); - scrollToEnd(); - - await waitFor(() => { - expect(loadMore).toHaveBeenCalledTimes(2); - }); - }); - it('should call load latest messages when the scroll to bottom button is pressed', async () => { const user1 = generateUser(); const messages = Array.from({ length: 10 }, (_, i) => diff --git a/package/src/store/__tests__/selectMessagesForChannels.test.ts b/package/src/store/__tests__/selectMessagesForChannels.test.ts deleted file mode 100644 index 1d7ac87b76..0000000000 --- a/package/src/store/__tests__/selectMessagesForChannels.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ChannelAPIResponse } from 'stream-chat'; - -import { generateChannelResponse } from '../../mock-builders/generator/channel'; -import { generateMessage } from '../../mock-builders/generator/message'; -import { BetterSqlite } from '../../test-utils/BetterSqlite'; -import { getChannels } from '../apis/getChannels'; -import { selectMessagesForChannels } from '../apis/queries/selectMessagesForChannels'; -import { upsertChannels } from '../apis/upsertChannels'; -import { SqliteClient } from '../SqliteClient'; - -/** - * The client reads the cached page as a channel list page of `message_limit` messages, so a page of - * a different size makes it guess wrongly whether older messages exist, for example that a channel - * with a long history starts at its cached messages. - */ -describe('selectMessagesForChannels', () => { - let cid: string; - let messages: ReturnType[]; - - beforeEach(async () => { - await SqliteClient.initializeDatabase(); - await BetterSqlite.openDB(); - - const channelResponse = generateChannelResponse({ members: [], messages: [] }); - cid = channelResponse.channel.cid as string; - messages = Array.from({ length: 30 }, (_, i) => - generateMessage({ cid, timestamp: new Date(Date.UTC(2020, 0, 1, 0, i)) }), - ); - channelResponse.messages = messages as unknown as typeof channelResponse.messages; - await upsertChannels({ - channels: [channelResponse] as unknown as ChannelAPIResponse[], - isLatestMessagesSet: true, - }); - }); - - afterEach(() => { - BetterSqlite.dropAllTables(); - BetterSqlite.closeDB(); - }); - - const ids = (rows: { id: string }[]) => rows.map((row) => row.id); - - it("returns a channel's newest 25 messages, oldest first, by default", async () => { - const rows = await selectMessagesForChannels([cid]); - - expect(ids(rows)).toEqual(ids(messages.slice(-25))); - }); - - it("returns a channel's newest `messageLimit` messages", async () => { - expect(ids(await selectMessagesForChannels([cid], 10))).toEqual(ids(messages.slice(-10))); - expect(ids(await selectMessagesForChannels([cid], 50))).toEqual(ids(messages)); - }); - - it('applies the limit that getChannels is given', async () => { - const [channel] = await getChannels({ - channelIds: [cid], - currentUserId: 'some-user', - messageLimit: 10, - }); - - expect(ids(channel.messages)).toEqual(ids(messages.slice(-10))); - }); -}); diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 1b7fed52fb..87ae9641ed 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -12,18 +12,15 @@ import type { TableRow, TableRowJoinedUser } from '../types'; export const getChannelMessages = async ({ channelIds, currentUserId, - messageLimit, }: { channelIds: string[]; currentUserId: string; - messageLimit?: number; }) => { SqliteClient.logger?.('info', 'getChannelMessages', { channelIds, currentUserId, - messageLimit, }); - const messageRows = await selectMessagesForChannels(channelIds, messageLimit); + const messageRows = await selectMessagesForChannels(channelIds); const messageIds = messageRows.map(({ id }) => id); // Populate the message reactions. diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index c7f9e89743..094981c2aa 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -16,20 +16,17 @@ import { SqliteClient } from '../SqliteClient'; * @param {Object} param * @param {Array} param.channelIds List of channel ids to fetch. * @param {Array} param.currentUserId Id of the current logged in user. - * @param {number} param.messageLimit How many of each channel's newest messages to return, 25 by default. * * @returns {Array} Channels with enriched state. */ export const getChannels = async ({ channelIds, currentUserId, - messageLimit, }: { channelIds: string[]; currentUserId: string; - messageLimit?: number; }): Promise[]> => { - SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId, messageLimit }); + SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages, cidVsActiveLocations] = await Promise.all([ @@ -40,7 +37,6 @@ export const getChannels = async ({ getChannelMessages({ channelIds, currentUserId, - messageLimit, }), getChannelActiveLocations({ channelIds }), ]); diff --git a/package/src/store/apis/getChannelsForFilterSort.ts b/package/src/store/apis/getChannelsForFilterSort.ts index f029e68037..6ecd35d3b3 100644 --- a/package/src/store/apis/getChannelsForFilterSort.ts +++ b/package/src/store/apis/getChannelsForFilterSort.ts @@ -48,6 +48,5 @@ export const getChannelsForFilterSort = async ({ return await getChannels({ channelIds, currentUserId, - messageLimit: options?.message_limit, }); }; diff --git a/package/src/store/apis/queries/selectMessagesForChannels.ts b/package/src/store/apis/queries/selectMessagesForChannels.ts index c6257bf039..c7c62074f5 100644 --- a/package/src/store/apis/queries/selectMessagesForChannels.ts +++ b/package/src/store/apis/queries/selectMessagesForChannels.ts @@ -2,12 +2,8 @@ import { tables } from '../../schema'; import { SqliteClient } from '../../SqliteClient'; import type { TableRowJoinedUser } from '../../types'; -/** The `message_limit` the client assumes for a channel list query that sets none. */ -export const DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT = 25; - export const selectMessagesForChannels = async ( cids: string[], - messageLimit: number = DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT, ): Promise[]> => { const questionMarks = Array(cids.length).fill('?').join(','); const messagesColumnNames = Object.keys(tables.messages.columns) @@ -19,7 +15,6 @@ export const selectMessagesForChannels = async ( SqliteClient.logger?.('info', 'selectMessagesForChannels', { cids, - messageLimit, }); const result = await SqliteClient.executeSql( @@ -43,9 +38,9 @@ export const selectMessagesForChannels = async ( LEFT JOIN users b ON b.id = a.userId - WHERE RowNum <= ? + WHERE RowNum < 25 ORDER BY a.createdAt ASC`, - [...cids, messageLimit], + cids, ); return result.map((r) => JSON.parse(r.value)); From 59528b707ce332c99c7002767ef7ae01fa203beb Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:03:53 +0100 Subject: [PATCH 5/6] fix: offline message limit pagination (#3907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal Fixes https://github.com/GetStream/stream-chat-react-native/issues/3903 and the related pagination problems found while testing it. Linear ticket: https://linear.app/stream/issue/RN-471/offline-support-channels-hydrated-from-the-local-db-get-hasprev-false With offline support, channels hydrated from the offline DB could end up with `hasPrev = false` even when the server has older messages, so scrolling up never requests them. The offline DB returns at most 24 messages per channel (`RowNum < 25`). Testing it also showed that the message lists stop paginating after a single failed request and that `MessageFlashList` cannot load older messages when the loaded ones do not fill the screen. ## ๐Ÿ›  Implementation details - Channels loaded from the offline DB can get `hasPrev = false` when the server still has older messages, so scrolling up never loads them. The offline DB returns at most 24 messages per channel and ignores the list's `message_limit`, so the client reads a short cached page as the start of the channel. Fixed in 06e1be316, together with GetStream/stream-chat-js#1958. The offline DB now returns the newest `message_limit` messages per channel (25 when not set), and the client reads that page against the same limit. - The offline DB can show messages with gaps between them. If messages 1-10 were cached in one session and 16-25 in a later one, they show up back to back with 11-15 missing. Fixed in 06e1be316, since only the newest `message_limit` messages are read now. - After a failed request (offline, timeout or w/e), `MessageList` and `MessageFlashList` stop paginating until the number of messages changes. `loadMore`, `loadMoreRecent` and `loadMoreThread` swallow their errors, so the lists treat the failure as a successful load. Fixed in 65b9bf574, so if a load adds nothing the list tries again on the next scroll after a 2 second backoff. - `MessageFlashList` can't load older messages when the loaded ones don't fill the screen, because it only paginates on scroll events and a list that short can't scroll. Fixed in 7e6b5e7b5 with the same prefill `MessageList` has. ## ๐ŸŽจ UI Changes / ## ๐Ÿงช Testing / ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] 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/E2ETestApp/package.json | 2 +- examples/ExpoChatGPTSample/package.json | 2 +- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/package.json | 2 +- package/package.json | 2 +- .../MessageList/MessageFlashList.tsx | 66 ++++++++++++++++--- .../components/MessageList/MessageList.tsx | 27 ++++++-- .../__tests__/MessageList.test.tsx | 51 ++++++++++++++ .../selectMessagesForChannels.test.ts | 63 ++++++++++++++++++ package/src/store/apis/getChannelMessages.ts | 5 +- package/src/store/apis/getChannels.ts | 6 +- .../store/apis/getChannelsForFilterSort.ts | 1 + .../apis/queries/selectMessagesForChannels.ts | 9 ++- yarn.lock | 18 ++--- 14 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 package/src/store/__tests__/selectMessagesForChannels.test.ts diff --git a/examples/E2ETestApp/package.json b/examples/E2ETestApp/package.json index 84093f57a0..ea8298ce08 100644 --- a/examples/E2ETestApp/package.json +++ b/examples/E2ETestApp/package.json @@ -23,7 +23,7 @@ "react-native-svg": "^15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.3", "stream-chat-react-native": "workspace:^" }, "devDependencies": { diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 73a01a0e80..b477c3c1ce 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -29,7 +29,7 @@ "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.3", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", "victory-native": "^42.0.1" diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 7ea24ba04c..937eed40dc 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.2.2", "react-native-web": "~0.21.4", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.3", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 6b230be81d..7bc0bb40d1 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.3", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 673f176a05..50a48e8fc9 100644 --- a/package/package.json +++ b/package/package.json @@ -84,7 +84,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.3", "use-sync-external-store": "^1.5.0", "zod": "^4.1.12" }, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index f3658c10b2..80a1a89edb 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -4,6 +4,7 @@ import { LayoutChangeEvent, ScrollViewProps, StyleSheet, View, useColorScheme } import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; +import debounce from 'lodash/debounce'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -147,7 +148,7 @@ type MessageFlashListPropsWithContext = Pick< > & Pick< ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' + 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadHasMore' | 'threadInstance' > & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want @@ -346,6 +347,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => hasPendingInitialTargetLoad, targetedMessage, thread, + threadHasMore, threadInstance, threadList = false, } = props; @@ -835,6 +837,16 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * once per content length. */ + /** + * How many messages the list pages through are loaded: the thread's replies in a thread list, the + * channel's messages otherwise. + */ + const getLoadedMessageCount = useStableCallback(() => + threadList && thread?.id + ? (channel?.state.threads[thread.id]?.length ?? 0) + : (channel?.state.messages.length ?? 0), + ); + /** * 1. Makes a call to `loadMore` function, which queries more older messages. * 2. Ensures that we call `loadMore`, once per content length @@ -853,8 +865,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onStartReachedInPromise.current = null; + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -869,12 +884,13 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -885,7 +901,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => */ const maybeCallOnEndReached = useStableCallback(async () => { // If onStartReached has already been called for given data length, then ignore. - if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) { + if ( + (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) || + (threadList && !threadHasMore) + ) { return; } @@ -893,9 +912,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onEndReachedInPromise.current = null; - + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -911,8 +932,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onStartReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -1100,6 +1122,31 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => currentListHeightRef.current = height; }); + const debouncedPrefillMessages = useMemo( + () => + debounce( + (viewportHeight: number, contentHeight: number) => { + if (viewportHeight >= contentHeight) { + maybeCallOnEndReached(); + } + }, + 500, + { + leading: false, + trailing: true, + }, + ), + [maybeCallOnEndReached], + ); + + const onContentSizeChange = useStableCallback((width: number, height: number) => { + if (additionalFlashListProps?.onContentSizeChange) { + additionalFlashListProps.onContentSizeChange(width, height); + } + + debouncedPrefillMessages(currentListHeightRef.current ?? 0, height); + }); + const ListFooterComponent = useCallback(() => { if (FooterComponent) { return ; @@ -1172,6 +1219,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {...additionalFlashListPropsExcludingStyle} accessibilityActions={messageListAccessibilityActions} onAccessibilityAction={messageListOnAccessibilityAction} + onContentSizeChange={onContentSizeChange} /> )} @@ -1313,7 +1361,8 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); - const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); + const { loadMoreRecentThread, loadMoreThread, thread, threadHasMore, threadInstance } = + useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); @@ -1355,6 +1404,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { shouldShowUnreadUnderlay, targetedMessage, thread, + threadHasMore, threadInstance, threadList, }} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index baf66fb98c..d2d9269cf6 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -932,6 +932,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * once per content length. */ + /** + * How many messages the list pages through are loaded: the thread's replies in a thread list, the + * channel's messages otherwise. + */ + const getLoadedMessageCount = useStableCallback(() => + threadList && thread?.id + ? (channel?.state.threads[thread.id]?.length ?? 0) + : (channel?.state.messages.length ?? 0), + ); + /** * 1. Makes a call to `loadMoreRecent` function, which queries more recent messages. * 2. Ensures that we call `loadMoreRecent`, once per content length @@ -950,9 +960,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onStartReachedInPromise.current = null; - + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -967,12 +979,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -995,8 +1008,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onEndReachedInPromise.current = null; + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -1011,8 +1027,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onStartReachedInPromise.current) { await onStartReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index bfdee3cb8d..438140fad7 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -650,6 +650,57 @@ describe('MessageList pagination', () => { }); }); + it('tries loading older messages again after a load that loaded nothing', async () => { + const user1 = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages: Array.from({ length: 100 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + // resolves without loading anything, as `loadMore` does when its request fails, e.g. offline + const loadMore = jest.fn(() => Promise.resolve()); + mockedHook({ loadMore }); + + const { getByTestId } = render( + + + + + + + , + ); + + const scrollToEnd = () => + act(() => { + fireEvent(getByTestId('message-flat-list'), 'momentumScrollEnd', { + nativeEvent: { + contentOffset: { y: 1900 }, + contentSize: { height: 2000, width: 200 }, + layoutMeasurement: { height: 400, width: 200 }, + }, + }); + }); + + scrollToEnd(); + await waitFor(() => { + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + // released after the same 2 second backoff as a rejected load + await act(() => new Promise((resolve) => setTimeout(resolve, 2100))); + scrollToEnd(); + + await waitFor(() => { + expect(loadMore).toHaveBeenCalledTimes(2); + }); + }); + it('should call load latest messages when the scroll to bottom button is pressed', async () => { const user1 = generateUser(); const messages = Array.from({ length: 10 }, (_, i) => diff --git a/package/src/store/__tests__/selectMessagesForChannels.test.ts b/package/src/store/__tests__/selectMessagesForChannels.test.ts new file mode 100644 index 0000000000..1d7ac87b76 --- /dev/null +++ b/package/src/store/__tests__/selectMessagesForChannels.test.ts @@ -0,0 +1,63 @@ +import type { ChannelAPIResponse } from 'stream-chat'; + +import { generateChannelResponse } from '../../mock-builders/generator/channel'; +import { generateMessage } from '../../mock-builders/generator/message'; +import { BetterSqlite } from '../../test-utils/BetterSqlite'; +import { getChannels } from '../apis/getChannels'; +import { selectMessagesForChannels } from '../apis/queries/selectMessagesForChannels'; +import { upsertChannels } from '../apis/upsertChannels'; +import { SqliteClient } from '../SqliteClient'; + +/** + * The client reads the cached page as a channel list page of `message_limit` messages, so a page of + * a different size makes it guess wrongly whether older messages exist, for example that a channel + * with a long history starts at its cached messages. + */ +describe('selectMessagesForChannels', () => { + let cid: string; + let messages: ReturnType[]; + + beforeEach(async () => { + await SqliteClient.initializeDatabase(); + await BetterSqlite.openDB(); + + const channelResponse = generateChannelResponse({ members: [], messages: [] }); + cid = channelResponse.channel.cid as string; + messages = Array.from({ length: 30 }, (_, i) => + generateMessage({ cid, timestamp: new Date(Date.UTC(2020, 0, 1, 0, i)) }), + ); + channelResponse.messages = messages as unknown as typeof channelResponse.messages; + await upsertChannels({ + channels: [channelResponse] as unknown as ChannelAPIResponse[], + isLatestMessagesSet: true, + }); + }); + + afterEach(() => { + BetterSqlite.dropAllTables(); + BetterSqlite.closeDB(); + }); + + const ids = (rows: { id: string }[]) => rows.map((row) => row.id); + + it("returns a channel's newest 25 messages, oldest first, by default", async () => { + const rows = await selectMessagesForChannels([cid]); + + expect(ids(rows)).toEqual(ids(messages.slice(-25))); + }); + + it("returns a channel's newest `messageLimit` messages", async () => { + expect(ids(await selectMessagesForChannels([cid], 10))).toEqual(ids(messages.slice(-10))); + expect(ids(await selectMessagesForChannels([cid], 50))).toEqual(ids(messages)); + }); + + it('applies the limit that getChannels is given', async () => { + const [channel] = await getChannels({ + channelIds: [cid], + currentUserId: 'some-user', + messageLimit: 10, + }); + + expect(ids(channel.messages)).toEqual(ids(messages.slice(-10))); + }); +}); diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 87ae9641ed..1b7fed52fb 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -12,15 +12,18 @@ import type { TableRow, TableRowJoinedUser } from '../types'; export const getChannelMessages = async ({ channelIds, currentUserId, + messageLimit, }: { channelIds: string[]; currentUserId: string; + messageLimit?: number; }) => { SqliteClient.logger?.('info', 'getChannelMessages', { channelIds, currentUserId, + messageLimit, }); - const messageRows = await selectMessagesForChannels(channelIds); + const messageRows = await selectMessagesForChannels(channelIds, messageLimit); const messageIds = messageRows.map(({ id }) => id); // Populate the message reactions. diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index 094981c2aa..c7f9e89743 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -16,17 +16,20 @@ import { SqliteClient } from '../SqliteClient'; * @param {Object} param * @param {Array} param.channelIds List of channel ids to fetch. * @param {Array} param.currentUserId Id of the current logged in user. + * @param {number} param.messageLimit How many of each channel's newest messages to return, 25 by default. * * @returns {Array} Channels with enriched state. */ export const getChannels = async ({ channelIds, currentUserId, + messageLimit, }: { channelIds: string[]; currentUserId: string; + messageLimit?: number; }): Promise[]> => { - SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); + SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId, messageLimit }); const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages, cidVsActiveLocations] = await Promise.all([ @@ -37,6 +40,7 @@ export const getChannels = async ({ getChannelMessages({ channelIds, currentUserId, + messageLimit, }), getChannelActiveLocations({ channelIds }), ]); diff --git a/package/src/store/apis/getChannelsForFilterSort.ts b/package/src/store/apis/getChannelsForFilterSort.ts index 6ecd35d3b3..f029e68037 100644 --- a/package/src/store/apis/getChannelsForFilterSort.ts +++ b/package/src/store/apis/getChannelsForFilterSort.ts @@ -48,5 +48,6 @@ export const getChannelsForFilterSort = async ({ return await getChannels({ channelIds, currentUserId, + messageLimit: options?.message_limit, }); }; diff --git a/package/src/store/apis/queries/selectMessagesForChannels.ts b/package/src/store/apis/queries/selectMessagesForChannels.ts index c7c62074f5..c6257bf039 100644 --- a/package/src/store/apis/queries/selectMessagesForChannels.ts +++ b/package/src/store/apis/queries/selectMessagesForChannels.ts @@ -2,8 +2,12 @@ import { tables } from '../../schema'; import { SqliteClient } from '../../SqliteClient'; import type { TableRowJoinedUser } from '../../types'; +/** The `message_limit` the client assumes for a channel list query that sets none. */ +export const DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT = 25; + export const selectMessagesForChannels = async ( cids: string[], + messageLimit: number = DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT, ): Promise[]> => { const questionMarks = Array(cids.length).fill('?').join(','); const messagesColumnNames = Object.keys(tables.messages.columns) @@ -15,6 +19,7 @@ export const selectMessagesForChannels = async ( SqliteClient.logger?.('info', 'selectMessagesForChannels', { cids, + messageLimit, }); const result = await SqliteClient.executeSql( @@ -38,9 +43,9 @@ export const selectMessagesForChannels = async ( LEFT JOIN users b ON b.id = a.userId - WHERE RowNum < 25 + WHERE RowNum <= ? ORDER BY a.createdAt ASC`, - cids, + [...cids, messageLimit], ); return result.map((r) => JSON.parse(r.value)); diff --git a/yarn.lock b/yarn.lock index 44741c2181..6cfada1df0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7344,7 +7344,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-web: "npm:~0.21.4" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.2" + stream-chat: "npm:^9.54.3" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -10040,7 +10040,7 @@ __metadata: react-native-svg: "npm:^15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^9.54.2" + stream-chat: "npm:^9.54.3" stream-chat-react-native: "workspace:^" typescript: "npm:6.0.3" languageName: unknown @@ -11432,7 +11432,7 @@ __metadata: react-native-svg: "npm:15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.2" + stream-chat: "npm:^9.54.3" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -19352,7 +19352,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-video: "npm:^6.19.3" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^9.54.2" + stream-chat: "npm:^9.54.3" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -20173,7 +20173,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.0" react-test-renderer: "npm:19.3.0" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.54.2" + stream-chat: "npm:^9.54.3" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -20261,9 +20261,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.54.2": - version: 9.54.2 - resolution: "stream-chat@npm:9.54.2" +"stream-chat@npm:^9.54.3": + version: 9.54.3 + resolution: "stream-chat@npm:9.54.3" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -20279,7 +20279,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/ebe3835165ad942c1eb7c3c1e4d5b2d24f9638ce6b849918efe945cf4a41a856c3287b12e9f0ab166a128276f36a74f4482da0c505c3e5a339b47f90b800b65f + checksum: 10c0/ef2ffcb83a8e18ec9b9742d62c16a2575d9b8a8895ac2469490a39f53afdf6d2135f860297b2a95da8d89d6a0e687cd472fc64f85dbeca1cced630bf1940d354 languageName: node linkType: hard From 07e1b0edf04926b3fdc4df74328e70104cb1d2f7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:32:11 +0100 Subject: [PATCH 6/6] fix: token provider rejection during reconnect (#3913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal Linear ticket: https://linear.app/stream/issue/REACT-1472/unhandled-rejection-when-tokenprovider-fails-during-reconnect When the `tokenProvider` rejects while the WebSocket reconnects after an expired token, `stream-chat` lets the rejection escape as an unhandled promise rejection. Integrators can't catch it, and mobile error trackers report it as a crash (e.g. a token refresh in a network dead spot). ## ๐Ÿ›  Implementation details Bumps `stream-chat` to `^9.54.4`, which contains the fix (GetStream/stream-chat-js#1962). A failed refresh is now handled like a failed connect and the reconnect retries with the usual backoff. Bumped in `package/` and the example apps (`SampleApp`, `E2ETestApp`, `ExpoMessaging`, `ExpoChatGPTSample`). No SDK code changes. ## ๐ŸŽจ UI Changes / ## ๐Ÿงช Testing `yarn test:typecheck` and `yarn test:unit` pass. ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] 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 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/E2ETestApp/package.json | 2 +- examples/ExpoChatGPTSample/package.json | 2 +- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/package.json | 2 +- package/package.json | 2 +- yarn.lock | 18 +++++++++--------- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/E2ETestApp/package.json b/examples/E2ETestApp/package.json index ea8298ce08..0d3aff83e9 100644 --- a/examples/E2ETestApp/package.json +++ b/examples/E2ETestApp/package.json @@ -23,7 +23,7 @@ "react-native-svg": "^15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.3", + "stream-chat": "^9.54.4", "stream-chat-react-native": "workspace:^" }, "devDependencies": { diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index b477c3c1ce..7d6dc5c668 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -29,7 +29,7 @@ "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.3", + "stream-chat": "^9.54.4", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", "victory-native": "^42.0.1" diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 937eed40dc..e134125af5 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.2.2", "react-native-web": "~0.21.4", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.3", + "stream-chat": "^9.54.4", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 7bc0bb40d1..38db2a0178 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.3", + "stream-chat": "^9.54.4", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 50a48e8fc9..85a4db05d1 100644 --- a/package/package.json +++ b/package/package.json @@ -84,7 +84,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^9.54.3", + "stream-chat": "^9.54.4", "use-sync-external-store": "^1.5.0", "zod": "^4.1.12" }, diff --git a/yarn.lock b/yarn.lock index 6cfada1df0..ab453e7250 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7344,7 +7344,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-web: "npm:~0.21.4" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.3" + stream-chat: "npm:^9.54.4" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -10040,7 +10040,7 @@ __metadata: react-native-svg: "npm:^15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^9.54.3" + stream-chat: "npm:^9.54.4" stream-chat-react-native: "workspace:^" typescript: "npm:6.0.3" languageName: unknown @@ -11432,7 +11432,7 @@ __metadata: react-native-svg: "npm:15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.3" + stream-chat: "npm:^9.54.4" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -19352,7 +19352,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-video: "npm:^6.19.3" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^9.54.3" + stream-chat: "npm:^9.54.4" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -20173,7 +20173,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.0" react-test-renderer: "npm:19.3.0" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.54.3" + stream-chat: "npm:^9.54.4" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -20261,9 +20261,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.54.3": - version: 9.54.3 - resolution: "stream-chat@npm:9.54.3" +"stream-chat@npm:^9.54.4": + version: 9.54.4 + resolution: "stream-chat@npm:9.54.4" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -20279,7 +20279,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/ef2ffcb83a8e18ec9b9742d62c16a2575d9b8a8895ac2469490a39f53afdf6d2135f860297b2a95da8d89d6a0e687cd472fc64f85dbeca1cced630bf1940d354 + checksum: 10c0/52f399b7f62a40e05025412e24a3a052e5cfeadaf84c8e71b46c7ac7273326be50d8c271b9e3ba90363c6d8ae54ea38bf0d91dee9aa0d74bcb5bf86750d8e866 languageName: node linkType: hard