From 341966e6b5c5af62bba827f0e43e3a12fabdf959 Mon Sep 17 00:00:00 2001 From: Tasfin Mahmud Date: Fri, 1 May 2026 03:51:29 +0600 Subject: [PATCH 1/2] fix: refactor MentionsTab, StarredMessagesTab, and PinnedMessagesTab to use useInfiniteQuery Replaces blocking sequential for-loop fetching with useInfiniteQuery for MentionsTab, StarredMessagesTab, and PinnedMessagesTab components. Before: All pages were fetched sequentially in a for loop inside useQuery, blocking the UI until every page was loaded. After: Only the first page is fetched initially. Additional pages are loaded on-demand as the user scrolls to the bottom of the list using Virtuoso's endReached callback. Changes: - MentionsTab: useQuery -> useInfiniteQuery with page-by-page fetching - StarredMessagesTab: useQuery -> useInfiniteQuery with page-by-page fetching - PinnedMessagesTab: useQuery -> useInfiniteQuery with page-by-page fetching - MessageListTab: Accept individual props instead of UseQueryResult, add endReached prop to Virtuoso, show loading indicator for next page - Use isFetching guard in handleEndReached to prevent concurrent fetches Fixes #39237 --- .../views/room/contextualBar/MentionsTab.tsx | 47 ++++++++++++----- .../room/contextualBar/MessageListTab.tsx | 29 +++++++---- .../room/contextualBar/PinnedMessagesTab.tsx | 48 ++++++++++++----- .../room/contextualBar/StarredMessagesTab.tsx | 52 +++++++++++++------ 4 files changed, 125 insertions(+), 51 deletions(-) diff --git a/apps/meteor/client/views/room/contextualBar/MentionsTab.tsx b/apps/meteor/client/views/room/contextualBar/MentionsTab.tsx index d2bc622767218..6e66b005065cf 100644 --- a/apps/meteor/client/views/room/contextualBar/MentionsTab.tsx +++ b/apps/meteor/client/views/room/contextualBar/MentionsTab.tsx @@ -1,36 +1,53 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import MessageListTab from './MessageListTab'; import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; import { useRoom } from '../contexts/RoomContext'; +const COUNT = 50; + const MentionsTab = (): ReactElement => { const getMentionedMessages = useEndpoint('GET', '/v1/chat.getMentionedMessages'); const room = useRoom(); - const mentionedMessagesQueryResult = useQuery({ + const mentionedMessagesQueryResult = useInfiniteQuery({ queryKey: ['rooms', room._id, 'mentioned-messages'] as const, - queryFn: async () => { - const messages: IMessage[] = []; + queryFn: async ({ pageParam }) => { + const result = await getMentionedMessages({ roomId: room._id, offset: pageParam, count: COUNT }); + return { + messages: result.messages.map(mapMessageFromApi), + total: result.total, + count: result.count, + offset: pageParam, + }; + }, - for ( - let offset = 0, result = await getMentionedMessages({ roomId: room._id, offset: 0 }); - result.count > 0; - offset += result.count, result = await getMentionedMessages({ roomId: room._id, offset }) - ) { - messages.push(...result.messages.map(mapMessageFromApi)); - } + initialPageParam: 0, - return messages; + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.offset + lastPage.count; + return nextOffset < lastPage.total ? nextOffset : undefined; }, }); + const messages = useMemo( + () => mentionedMessagesQueryResult.data?.pages.flatMap((page) => page.messages) ?? [], + [mentionedMessagesQueryResult.data], + ); + + const handleEndReached = useCallback(() => { + if (mentionedMessagesQueryResult.hasNextPage && !mentionedMessagesQueryResult.isFetching) { + mentionedMessagesQueryResult.fetchNextPage(); + } + }, [mentionedMessagesQueryResult]); + const { t } = useTranslation(); return ( @@ -39,7 +56,11 @@ const MentionsTab = (): ReactElement => { title={t('Mentions')} emptyResultMessage={t('No_mentions_found')} context='mentions' - queryResult={mentionedMessagesQueryResult} + messages={messages} + isLoading={mentionedMessagesQueryResult.isLoading} + isSuccess={mentionedMessagesQueryResult.isSuccess} + isFetchingNextPage={mentionedMessagesQueryResult.isFetchingNextPage} + onEndReached={handleEndReached} /> ); }; diff --git a/apps/meteor/client/views/room/contextualBar/MessageListTab.tsx b/apps/meteor/client/views/room/contextualBar/MessageListTab.tsx index 16c83d3cacf16..67671d935524e 100644 --- a/apps/meteor/client/views/room/contextualBar/MessageListTab.tsx +++ b/apps/meteor/client/views/room/contextualBar/MessageListTab.tsx @@ -13,7 +13,6 @@ import { ContextualbarDialog, } from '@rocket.chat/ui-client'; import { useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; -import type { UseQueryResult } from '@tanstack/react-query'; import type { ReactElement, ReactNode } from 'react'; import { useCallback } from 'react'; import { Virtuoso } from 'react-virtuoso'; @@ -32,10 +31,14 @@ type MessageListTabProps = { title: ReactNode; emptyResultMessage: string; context: MessageActionContext; - queryResult: UseQueryResult; + messages: IMessage[]; + isLoading: boolean; + isSuccess: boolean; + isFetchingNextPage?: boolean; + onEndReached?: () => void; }; -const MessageListTab = ({ iconName, title, emptyResultMessage, context, queryResult }: MessageListTabProps): ReactElement => { +const MessageListTab = ({ iconName, title, emptyResultMessage, context, messages, isLoading, isSuccess, isFetchingNextPage, onEndReached }: MessageListTabProps): ReactElement => { const formatDate = useFormatDate(); const showUserAvatar = !!useUserPreference('displayAvatars'); @@ -54,26 +57,27 @@ const MessageListTab = ({ iconName, title, emptyResultMessage, context, queryRes - {queryResult.isLoading && ( + {isLoading && ( )} - {queryResult.isSuccess && ( + {isSuccess && ( <> - {queryResult.data.length === 0 && } + {messages.length === 0 && } - {queryResult.data.length > 0 && ( + {messages.length > 0 && ( { - const previous = queryResult.data[index - 1]; + const previous = messages[index - 1]; const newDay = isMessageNewDay(message, previous); @@ -111,6 +115,11 @@ const MessageListTab = ({ iconName, title, emptyResultMessage, context, queryRes )} )} + {isFetchingNextPage && ( + + + + )} ); diff --git a/apps/meteor/client/views/room/contextualBar/PinnedMessagesTab.tsx b/apps/meteor/client/views/room/contextualBar/PinnedMessagesTab.tsx index 7c67dcf94b4b1..cee619c2218f5 100644 --- a/apps/meteor/client/views/room/contextualBar/PinnedMessagesTab.tsx +++ b/apps/meteor/client/views/room/contextualBar/PinnedMessagesTab.tsx @@ -1,7 +1,8 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import MessageListTab from './MessageListTab'; @@ -9,29 +10,46 @@ import { onClientMessageReceived } from '../../../lib/onClientMessageReceived'; import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; import { useRoom } from '../contexts/RoomContext'; +const COUNT = 50; + const PinnedMessagesTab = (): ReactElement => { const getPinnedMessages = useEndpoint('GET', '/v1/chat.getPinnedMessages'); const room = useRoom(); - const pinnedMessagesQueryResult = useQuery({ + const pinnedMessagesQueryResult = useInfiniteQuery({ queryKey: ['rooms', room._id, 'pinned-messages'] as const, - queryFn: async () => { - const messages: IMessage[] = []; + queryFn: async ({ pageParam }) => { + const result = await getPinnedMessages({ roomId: room._id, offset: pageParam, count: COUNT }); + const processedMessages = await Promise.all(result.messages.map(mapMessageFromApi).map(onClientMessageReceived)); + return { + messages: processedMessages, + total: result.total, + count: result.count, + offset: pageParam, + }; + }, - for ( - let offset = 0, result = await getPinnedMessages({ roomId: room._id, offset: 0 }); - result.count > 0; - offset += result.count, result = await getPinnedMessages({ roomId: room._id, offset }) - ) { - messages.push(...result.messages.map(mapMessageFromApi)); - } + initialPageParam: 0, - return Promise.all(messages.map(onClientMessageReceived)); + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.offset + lastPage.count; + return nextOffset < lastPage.total ? nextOffset : undefined; }, }); + const messages = useMemo( + () => pinnedMessagesQueryResult.data?.pages.flatMap((page) => page.messages) ?? [], + [pinnedMessagesQueryResult.data], + ); + + const handleEndReached = useCallback(() => { + if (pinnedMessagesQueryResult.hasNextPage && !pinnedMessagesQueryResult.isFetching) { + pinnedMessagesQueryResult.fetchNextPage(); + } + }, [pinnedMessagesQueryResult]); + const { t } = useTranslation(); return ( @@ -40,7 +58,11 @@ const PinnedMessagesTab = (): ReactElement => { title={t('Pinned_Messages')} emptyResultMessage={t('No_pinned_messages')} context='pinned' - queryResult={pinnedMessagesQueryResult} + messages={messages} + isLoading={pinnedMessagesQueryResult.isLoading} + isSuccess={pinnedMessagesQueryResult.isSuccess} + isFetchingNextPage={pinnedMessagesQueryResult.isFetchingNextPage} + onEndReached={handleEndReached} /> ); }; diff --git a/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx b/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx index 38791d1b2af98..1b00fb0217e65 100644 --- a/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx +++ b/apps/meteor/client/views/room/contextualBar/StarredMessagesTab.tsx @@ -1,6 +1,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import MessageListTab from './MessageListTab'; @@ -9,28 +10,45 @@ import { roomsQueryKeys } from '../../../lib/queryKeys'; import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; import { useRoom } from '../contexts/RoomContext'; +const COUNT = 50; + const StarredMessagesTab = () => { const getStarredMessages = useEndpoint('GET', '/v1/chat.getStarredMessages'); const room = useRoom(); - const starredMessagesQueryResult = useQuery({ + const starredMessagesQueryResult = useInfiniteQuery({ queryKey: roomsQueryKeys.starredMessages(room._id), - queryFn: async () => { - const messages: IMessage[] = []; - - for ( - let offset = 0, result = await getStarredMessages({ roomId: room._id, offset: 0 }); - result.count > 0; - offset += result.count, result = await getStarredMessages({ roomId: room._id, offset }) - ) { - messages.push(...result.messages.map(mapMessageFromApi)); - } - - return Promise.all(messages.map(onClientMessageReceived)); + queryFn: async ({ pageParam }) => { + const result = await getStarredMessages({ roomId: room._id, offset: pageParam, count: COUNT }); + const processedMessages = await Promise.all(result.messages.map(mapMessageFromApi).map(onClientMessageReceived)); + return { + messages: processedMessages, + total: result.total, + count: result.count, + offset: pageParam, + }; + }, + + initialPageParam: 0, + + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.offset + lastPage.count; + return nextOffset < lastPage.total ? nextOffset : undefined; }, }); + const messages = useMemo( + () => starredMessagesQueryResult.data?.pages.flatMap((page) => page.messages) ?? [], + [starredMessagesQueryResult.data], + ); + + const handleEndReached = useCallback(() => { + if (starredMessagesQueryResult.hasNextPage && !starredMessagesQueryResult.isFetching) { + starredMessagesQueryResult.fetchNextPage(); + } + }, [starredMessagesQueryResult]); + const { t } = useTranslation(); return ( @@ -39,7 +57,11 @@ const StarredMessagesTab = () => { title={t('Starred_Messages')} emptyResultMessage={t('No_starred_messages')} context='starred' - queryResult={starredMessagesQueryResult} + messages={messages} + isLoading={starredMessagesQueryResult.isLoading} + isSuccess={starredMessagesQueryResult.isSuccess} + isFetchingNextPage={starredMessagesQueryResult.isFetchingNextPage} + onEndReached={handleEndReached} /> ); }; From 86d6a4b48a1348eb05814b5246e8e78a2a44e6cd Mon Sep 17 00:00:00 2001 From: Tasfin Mahmud Date: Mon, 4 May 2026 00:26:09 +0600 Subject: [PATCH 2/2] chore: add changeset for infinite query refactor --- .changeset/perf-infinite-query-message-tabs.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perf-infinite-query-message-tabs.md diff --git a/.changeset/perf-infinite-query-message-tabs.md b/.changeset/perf-infinite-query-message-tabs.md new file mode 100644 index 0000000000000..19b380d1c9137 --- /dev/null +++ b/.changeset/perf-infinite-query-message-tabs.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +perf: use useInfiniteQuery to prevent UI freeze in message tabs