diff --git a/.changeset/cool-flowers-stop.md b/.changeset/cool-flowers-stop.md
new file mode 100644
index 0000000000000..c45b3306ffc0c
--- /dev/null
+++ b/.changeset/cool-flowers-stop.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/i18n': patch
+'@rocket.chat/meteor': patch
+---
+
+Fixes workspace freezing when rendering massive markdown messages
diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.spec.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.spec.tsx
new file mode 100644
index 0000000000000..315bbf103da46
--- /dev/null
+++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.spec.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react';
+
+import { QuoteAttachment } from './QuoteAttachment';
+
+jest.mock('../../hooks/useMaxMessageParseSize', () => ({
+ useMaxMessageParseSize: () => 100,
+}));
+
+jest.mock('@rocket.chat/ui-contexts', () => ({
+ useUserPreference: () => true,
+}));
+
+jest.mock('../../../../hooks/useTimeAgo', () => ({
+ useTimeAgo: () => (date: Date) => date.toISOString(),
+}));
+
+jest.mock('../../MessageContentBody', () => ({
+ __esModule: true,
+ default: () =>
,
+}));
+
+const baseAttachment = {
+ author_name: 'User',
+ author_icon: '',
+ ts: new Date(),
+ text: 'short text',
+ md: [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'short text' }] }],
+};
+
+describe('QuoteAttachment', () => {
+ it('renders MessageContentBody when text length is within maxMessageParseSize', () => {
+ render();
+ expect(screen.getByTestId('message-content-body')).toBeInTheDocument();
+ });
+
+ it('renders plain text when text exceeds maxMessageParseSize', () => {
+ const longText = 'a'.repeat(101);
+ const attachment = { ...baseAttachment, text: longText };
+
+ render();
+
+ expect(screen.queryByTestId('message-content-body')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx
index 2a4f975c6641a..271de136a2e8a 100644
--- a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx
+++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx
@@ -13,6 +13,7 @@ import AttachmentAuthorName from './structure/AttachmentAuthorName';
import AttachmentContent from './structure/AttachmentContent';
import AttachmentDetails from './structure/AttachmentDetails';
import AttachmentInner from './structure/AttachmentInner';
+import { useMaxMessageParseSize } from '../../hooks/useMaxMessageParseSize';
// TODO: remove this team collaboration
const quoteStyles = css`
@@ -38,6 +39,7 @@ type QuoteAttachmentProps = {
export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElement => {
const formatTime = useTimeAgo();
const displayAvatarPreference = useUserPreference('displayAvatars');
+ const maxMessageParseSize = useMaxMessageParseSize();
return (
<>
@@ -71,7 +73,11 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem
)}
- {attachment.md ? : attachment.text.substring(attachment.text.indexOf('\n') + 1)}
+ {attachment.text.length <= maxMessageParseSize && attachment.md ? (
+
+ ) : (
+ attachment.text.substring(attachment.text.indexOf('\n') + 1)
+ )}
>
diff --git a/apps/meteor/client/components/message/hooks/useMaxMessageParseSize.ts b/apps/meteor/client/components/message/hooks/useMaxMessageParseSize.ts
new file mode 100644
index 0000000000000..342a03aa0d7a5
--- /dev/null
+++ b/apps/meteor/client/components/message/hooks/useMaxMessageParseSize.ts
@@ -0,0 +1,18 @@
+import { useSetting } from '@rocket.chat/ui-contexts';
+import { useMemo } from 'react';
+
+import { MESSAGE_PARSE_HARD_LIMIT } from '../../../lib/constants';
+
+/**
+ * Returns the maximum allowed size for message parsing.
+ * Uses Math.min to ensure it never exceeds the hard limit to avoid performance issues.
+ * Always returns a number, never null or undefined.
+ */
+export const useMaxMessageParseSize = (): number => {
+ const settingValue = useSetting('Message_MaxAllowedSize', 5000);
+
+ return useMemo(() => {
+ const maxSize = typeof settingValue === 'number' && settingValue > 0 ? settingValue : 5000;
+ return Math.min(maxSize, MESSAGE_PARSE_HARD_LIMIT);
+ }, [settingValue]);
+};
diff --git a/apps/meteor/client/components/message/hooks/useNormalizedMessage.spec.ts b/apps/meteor/client/components/message/hooks/useNormalizedMessage.spec.ts
new file mode 100644
index 0000000000000..b16ad0bd55699
--- /dev/null
+++ b/apps/meteor/client/components/message/hooks/useNormalizedMessage.spec.ts
@@ -0,0 +1,68 @@
+import { renderHook } from '@testing-library/react';
+
+import { useNormalizedMessage } from './useNormalizedMessage';
+
+const mockParseMessageTextToAstMarkdown = jest.fn((msg: any, ..._args: any[]) => msg);
+
+jest.mock('../list/MessageListContext', () => ({
+ useMessageListKatex: () => null,
+ useMessageListAutoTranslate: () => ({
+ showAutoTranslate: () => false,
+ autoTranslateLanguage: '',
+ }),
+ useMessageListShowColors: () => false,
+}));
+
+jest.mock('../../../lib/parseMessageTextToAstMarkdown', () => ({
+ parseMessageTextToAstMarkdown: (msg: any, ...args: any[]) => mockParseMessageTextToAstMarkdown(msg, ...args),
+}));
+
+jest.mock('../../../views/room/MessageList/hooks/useAutoLinkDomains', () => ({ useAutoLinkDomains: () => [] }));
+
+const baseMessage = {
+ _id: 'msg1',
+ rid: 'room1',
+ u: { _id: 'u1', username: 'user', name: 'User' },
+ ts: new Date(),
+ _updatedAt: new Date(),
+};
+
+describe('useNormalizedMessage', () => {
+ beforeEach(() => {
+ mockParseMessageTextToAstMarkdown.mockClear();
+ });
+
+ it('should skip parsing and returns PARAGRAPH node when msg exceeds maxMessageParseSize', () => {
+ const longMsg = 'a'.repeat(101);
+ const message = { ...baseMessage, msg: longMsg };
+
+ const { result } = renderHook(() => useNormalizedMessage(message as any, 100));
+
+ expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
+ expect(result.current.md).toEqual([
+ {
+ type: 'PARAGRAPH',
+ value: [{ type: 'PLAIN_TEXT', value: longMsg }],
+ },
+ ]);
+ });
+
+ it('should call parseMessageTextToAstMarkdown when msg is within maxMessageParseSize', () => {
+ const message = { ...baseMessage, msg: 'Hello world' };
+
+ renderHook(() => useNormalizedMessage(message as any, 100));
+
+ expect(mockParseMessageTextToAstMarkdown).toHaveBeenCalledWith(message, expect.anything(), expect.anything());
+ });
+
+ it('should preserve attachments when bypassing parsing due to size', () => {
+ const longMsg = 'a'.repeat(101);
+ const attachments = [{ type: 'quote', text: 'quoted' }];
+ const message = { ...baseMessage, msg: longMsg, attachments };
+
+ const { result } = renderHook(() => useNormalizedMessage(message as any, 100));
+
+ expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
+ expect(result.current.attachments).toEqual(attachments);
+ });
+});
diff --git a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts
index a2a8047560ee4..8fda6978c0e46 100644
--- a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts
+++ b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts
@@ -57,7 +57,7 @@ const normalizeAttachments = (attachments: MessageAttachment[], name?: string, t
});
};
-export const useNormalizedMessage = (message: TMessage): MessageWithMdEnforced => {
+export const useNormalizedMessage = (message: TMessage, maxMessageParseSize: number): MessageWithMdEnforced => {
const katex = useMessageListKatex();
const katexEnabled = !!katex;
const customDomains = useAutoLinkDomains();
@@ -77,6 +77,21 @@ export const useNormalizedMessage = (message: TMessag
}),
};
+ if (message.msg && message.msg.length > maxMessageParseSize) {
+ return {
+ ...message,
+ md: [
+ {
+ type: 'PARAGRAPH',
+ value: [{ type: 'PLAIN_TEXT', value: message.msg }],
+ },
+ ],
+ attachments: message.attachments
+ ? normalizeAttachments(message.attachments, message.file?.name, message.file?.type)
+ : message.attachments,
+ };
+ }
+
const normalizedMessage = parseMessageTextToAstMarkdown(message, parseOptions, autoTranslateOptions);
if (normalizedMessage.attachments) {
@@ -88,5 +103,14 @@ export const useNormalizedMessage = (message: TMessag
}
return normalizedMessage;
- }, [showColors, customDomains, katexEnabled, katex?.dollarSyntaxEnabled, katex?.parenthesisSyntaxEnabled, message, autoTranslateOptions]);
+ }, [
+ showColors,
+ customDomains,
+ katexEnabled,
+ katex?.dollarSyntaxEnabled,
+ katex?.parenthesisSyntaxEnabled,
+ message,
+ autoTranslateOptions,
+ maxMessageParseSize,
+ ]);
};
diff --git a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx
index 7aec9412fd07d..1723cd0675948 100644
--- a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx
+++ b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx
@@ -31,6 +31,7 @@ import { useGoToThread } from '../../../views/room/hooks/useGoToThread';
import Emoji from '../../Emoji';
import { useShowTranslated } from '../list/MessageListContext';
import ThreadMessagePreviewBody from './threadPreview/ThreadMessagePreviewBody';
+import { useMaxMessageParseSize } from '../hooks/useMaxMessageParseSize';
type ThreadMessagePreviewProps = {
message: IThreadMessage;
@@ -51,7 +52,9 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }:
useCountSelected();
const messageType = parentMessage.isSuccess ? MessageTypes.getType(parentMessage.data) : null;
- const messageBody = useMessageBody(parentMessage.data);
+
+ const maxMessageParseSize = useMaxMessageParseSize();
+ const messageBody = useMessageBody(parentMessage.data, maxMessageParseSize);
const previewMessage = isParsedMessage(messageBody) ? { md: messageBody } : { msg: messageBody };
diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx
index 813d63e9f9130..6a6a7a05af46f 100644
--- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx
+++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx
@@ -18,6 +18,7 @@ import MessageActions from '../../content/MessageActions';
import Reactions from '../../content/Reactions';
import ThreadMetrics from '../../content/ThreadMetrics';
import UrlPreviews from '../../content/UrlPreviews';
+import { useMaxMessageParseSize } from '../../hooks/useMaxMessageParseSize';
import { useNormalizedMessage } from '../../hooks/useNormalizedMessage';
import { useOembedLayout } from '../../hooks/useOembedLayout';
import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery';
@@ -42,8 +43,9 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM
const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) };
const chat = useChat();
const { t } = useTranslation();
+ const maxMessageParseSize = useMaxMessageParseSize();
- const normalizedMessage = useNormalizedMessage(message);
+ const normalizedMessage = useNormalizedMessage(message, maxMessageParseSize);
const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending';
const quotes = normalizedMessage?.attachments?.filter(isQuoteAttachment) || [];
diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx
index d137162d0b06d..1f26ec6cd6431 100644
--- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx
+++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx
@@ -15,6 +15,7 @@ import Location from '../../content/Location';
import MessageActions from '../../content/MessageActions';
import Reactions from '../../content/Reactions';
import UrlPreviews from '../../content/UrlPreviews';
+import { useMaxMessageParseSize } from '../../hooks/useMaxMessageParseSize';
import { useNormalizedMessage } from '../../hooks/useNormalizedMessage';
import { useOembedLayout } from '../../hooks/useOembedLayout';
import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery';
@@ -33,10 +34,11 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem
const uid = useUserId();
const { enabled: readReceiptEnabled } = useMessageListReadReceipts();
const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) };
+ const maxMessageParseSize = useMaxMessageParseSize();
const { t } = useTranslation();
- const normalizedMessage = useNormalizedMessage(message);
+ const normalizedMessage = useNormalizedMessage(message, maxMessageParseSize);
const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending';
diff --git a/apps/meteor/client/lib/constants.ts b/apps/meteor/client/lib/constants.ts
index 70cae5deff224..cd57c13d92078 100644
--- a/apps/meteor/client/lib/constants.ts
+++ b/apps/meteor/client/lib/constants.ts
@@ -3,3 +3,4 @@ export const BIO_TEXT_MAX_LENGTH = 260;
export const VIDEOCONF_STACK_MAX_USERS = 6;
export const NAVIGATION_REGION_ID = 'navigation-region';
export const MAX_FILE_SIZE_PREVIEW = 10485760; // 10MB
+export const MESSAGE_PARSE_HARD_LIMIT = 20000;
diff --git a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.spec.ts b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.spec.ts
new file mode 100644
index 0000000000000..6890a3a7f4f0e
--- /dev/null
+++ b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.spec.ts
@@ -0,0 +1,64 @@
+import { renderHook } from '@testing-library/react';
+
+import { useMessageBody } from './useMessageBody';
+
+const mockParseMessageTextToAstMarkdown = jest.fn();
+
+jest.mock('./useAutoLinkDomains', () => ({
+ useAutoLinkDomains: () => [],
+}));
+
+jest.mock('../../../../components/message/list/MessageListContext', () => ({
+ useMessageListAutoTranslate: () => ({
+ showAutoTranslate: () => false,
+ autoTranslateLanguage: '',
+ }),
+}));
+
+jest.mock('../../../../lib/parseMessageTextToAstMarkdown', () => ({
+ parseMessageTextToAstMarkdown: (msg: any, ...args: any[]) => mockParseMessageTextToAstMarkdown(msg, ...args),
+}));
+
+const baseMessage = {
+ _id: 'msg1',
+ rid: 'room1',
+ u: { _id: 'u1', username: 'user', name: 'User' },
+ ts: new Date(),
+ _updatedAt: new Date(),
+};
+
+describe('useMessageBody', () => {
+ beforeEach(() => {
+ mockParseMessageTextToAstMarkdown.mockClear();
+ });
+
+ it('should return raw msg and skips parsing when msg exceeds maxMessageParseSize', () => {
+ const longMsg = 'a'.repeat(101);
+ const message = { ...baseMessage, msg: longMsg, md: [{ type: 'PARAGRAPH', value: [] }] };
+
+ const { result } = renderHook(() => useMessageBody(message as any, 100));
+
+ expect(result.current).toBe(longMsg);
+ expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
+ });
+
+ it('should call parser when message has md and is within maxMessageParseSize', () => {
+ const md = [{ type: 'PARAGRAPH', value: [] }];
+ const message = { ...baseMessage, msg: 'Hello world', md };
+ mockParseMessageTextToAstMarkdown.mockReturnValue({ ...message, md });
+
+ const { result } = renderHook(() => useMessageBody(message as any, 100));
+
+ expect(mockParseMessageTextToAstMarkdown).toHaveBeenCalledWith(message, expect.anything(), expect.anything());
+ expect(result.current).toBe(md);
+ });
+
+ it('should return raw msg without parsing when message has no md', () => {
+ const message = { ...baseMessage, msg: 'Hello world' };
+
+ const { result } = renderHook(() => useMessageBody(message as any, 100));
+
+ expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
+ expect(result.current).toBe('Hello world');
+ });
+});
diff --git a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx
index 94313e6925432..09c1201a9cced 100644
--- a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx
+++ b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx
@@ -6,7 +6,7 @@ import { useAutoLinkDomains } from './useAutoLinkDomains';
import { useMessageListAutoTranslate } from '../../../../components/message/list/MessageListContext';
import { parseMessageTextToAstMarkdown } from '../../../../lib/parseMessageTextToAstMarkdown';
-export const useMessageBody = (message: IMessage | undefined): string | Root => {
+export const useMessageBody = (message: IMessage | undefined, maxMessageParseSize: number): string | Root => {
const autoTranslateOptions = useMessageListAutoTranslate();
const customDomains = useAutoLinkDomains();
@@ -15,6 +15,10 @@ export const useMessageBody = (message: IMessage | undefined): string | Root =>
return '';
}
+ if (message.msg && message.msg.length > maxMessageParseSize) {
+ return message.msg;
+ }
+
if (message.md) {
const parseOptions: Options = {
customDomains,
@@ -39,5 +43,5 @@ export const useMessageBody = (message: IMessage | undefined): string | Root =>
}
return '';
- }, [message, customDomains, autoTranslateOptions]);
+ }, [message, customDomains, autoTranslateOptions, maxMessageParseSize]);
};
diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts
index 734d3831eebef..f1d6f433616b2 100644
--- a/apps/meteor/server/settings/message.ts
+++ b/apps/meteor/server/settings/message.ts
@@ -168,6 +168,7 @@ export const createMessageSettings = () =>
await this.add('Message_MaxAllowedSize', 5000, {
type: 'int',
public: true,
+ i18nDescription: 'Message_MaxAllowedSize_Description',
});
await this.add('Message_AllowConvertLongMessagesToAttachment', true, {
type: 'boolean',
diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json
index 52113dc9a07b8..8403a6c57deae 100644
--- a/packages/i18n/src/locales/en.i18n.json
+++ b/packages/i18n/src/locales/en.i18n.json
@@ -3517,6 +3517,7 @@
"Message_KeepHistory": "Keep Per Message Editing History",
"Message_MaxAll": "Maximum Channel Size for ALL Message",
"Message_MaxAllowedSize": "Maximum Allowed Characters Per Message",
+ "Message_MaxAllowedSize_Description": "Maximum number of characters allowed per message. Messages exceeding this limit will be displayed as plain text without markdown parsing.",
"Message_not_sent_try_again": "Message not sent. \nPlease try again",
"Message_QuoteChainLimit": "Maximum Number of Chained Quotes",
"Message_Read_Receipt_Enabled": "Show Read Receipts",