From 7427d94d50fd72afc452ab439c21ec4fc2352618 Mon Sep 17 00:00:00 2001
From: chanx <1243304602@qq.com>
Date: Thu, 13 Aug 2026 17:22:54 +0800
Subject: [PATCH] perf(web): stop re-rendering the whole chat transcript on
every stream flush (#18221)
---
web/src/components/markdown-content/index.tsx | 127 ++++++++++--------
web/src/hooks/logic-hooks.ts | 26 +++-
.../chat/chat-box/next-multiple-chat-box.tsx | 21 ++-
.../chat/chat-box/single-chat-box.tsx | 16 +--
.../hooks/use-message-references.ts | 71 ++++++++++
.../next-chats/hooks/use-send-chat-message.ts | 30 +++--
web/src/pages/next-chats/share/index.tsx | 13 +-
web/src/pages/next-chats/utils.ts | 46 ++-----
8 files changed, 215 insertions(+), 135 deletions(-)
create mode 100644 web/src/pages/next-chats/hooks/use-message-references.ts
diff --git a/web/src/components/markdown-content/index.tsx b/web/src/components/markdown-content/index.tsx
index b86c04b80d..55f0284c1b 100644
--- a/web/src/components/markdown-content/index.tsx
+++ b/web/src/components/markdown-content/index.tsx
@@ -22,7 +22,7 @@ import { citationMarkerReg } from '@/utils/citation-utils';
import { getExtension } from '@/utils/document-util';
import { getDirAttribute } from '@/utils/text-direction';
import DOMPurify from 'dompurify';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { memo, useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown';
import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex';
@@ -62,6 +62,53 @@ import { SafeImg } from '@/components/safe-img';
const getChunkIndex = (match: string) => parseCitationIndex(match);
+// Wraps every text node so citation markers can be replaced by React elements.
+// Defined at module scope: react-markdown rebuilds its whole processor whenever
+// a plugin's identity changes, and while an answer streams this component
+// re-renders many times a second.
+const rehypeWrapReference = () => {
+ return function wrapTextTransform(tree: any) {
+ visitParents(tree, 'text', (node, ancestors) => {
+ const latestAncestor = ancestors.at(-1);
+ if (
+ latestAncestor.tagName !== 'custom-typography' &&
+ latestAncestor.tagName !== 'code'
+ ) {
+ node.type = 'element';
+ node.tagName = 'custom-typography';
+ node.properties = {};
+ node.children = [{ type: 'text', value: node.value }];
+ }
+ });
+ };
+};
+
+const MarkdownRehypePlugins = [rehypeRaw, rehypeWrapReference, rehypeKatex];
+
+const MarkdownParagraph = ({ children, ...props }: any) => (
+
{children}
+);
+
+const MarkdownCode = (props: any) => {
+ const { children, className, ...rest } = props;
+ const restProps = omit(rest, 'node');
+ const match = /language-(\w+)/.exec(className || '');
+ return match ? (
+
+ {String(children).replace(/\n$/, '')}
+
+ ) : (
+
+ {children}
+
+ );
+};
+
const formatMetadataValue = (value: unknown) => {
if (Array.isArray(value)) return value.join(', ');
if (value === null || value === undefined) return '';
@@ -111,10 +158,18 @@ const MarkdownContent = ({
);
}, [content, loading, t]);
- useEffect(() => {
+ const documentIds = useMemo(() => {
const docAggs = reference?.doc_aggs;
- setDocumentIds(Array.isArray(docAggs) ? docAggs.map((x) => x.doc_id) : []);
- }, [reference, setDocumentIds]);
+ return Array.isArray(docAggs) ? docAggs.map((x) => x.doc_id) : [];
+ }, [reference?.doc_aggs]);
+
+ // Skipping the empty case matters: this component is mounted once per message,
+ // and setting a fresh empty array would re-render (and re-parse the markdown
+ // of) every message that carries no reference at all.
+ useEffect(() => {
+ if (documentIds.length === 0) return;
+ setDocumentIds(documentIds);
+ }, [documentIds, setDocumentIds]);
const handleDocumentButtonClick = useCallback(
(
@@ -137,23 +192,6 @@ const MarkdownContent = ({
[clickDocumentButton],
);
- const rehypeWrapReference = () => {
- return function wrapTextTransform(tree: any) {
- visitParents(tree, 'text', (node, ancestors) => {
- const latestAncestor = ancestors.at(-1);
- if (
- latestAncestor.tagName !== 'custom-typography' &&
- latestAncestor.tagName !== 'code'
- ) {
- node.type = 'element';
- node.tagName = 'custom-typography';
- node.properties = {};
- node.children = [{ type: 'text', value: node.value }];
- }
- });
- };
- };
-
const getReferenceInfo = useCallback(
(chunkIndex: number) => {
const chunks = reference?.chunks ?? [];
@@ -295,41 +333,24 @@ const MarkdownContent = ({
const dir = getDirAttribute(content.replace(citationMarkerReg, ''));
const showLoadingDots = useLoadingPause(loading, content);
+ const markdownComponents = useMemo(
+ () =>
+ ({
+ p: MarkdownParagraph,
+ 'custom-typography': ({ children }: { children: string }) =>
+ renderReference(children),
+ img: SafeImg,
+ code: MarkdownCode,
+ }) as any,
+ [renderReference],
+ );
+
return (
{children}
,
- 'custom-typography': ({ children }: { children: string }) =>
- renderReference(children),
- img: SafeImg,
- code(props: any) {
- const { children, className, ...rest } = props;
- const restProps = omit(rest, 'node');
- const match = /language-(\w+)/.exec(className || '');
- return match ? (
-
- {String(children).replace(/\n$/, '')}
-
- ) : (
-
- {children}
-
- );
- },
- } as any
- }
+ components={markdownComponents}
>
{contentWithCursor}
@@ -340,4 +361,4 @@ const MarkdownContent = ({
);
};
-export default MarkdownContent;
+export default memo(MarkdownContent);
diff --git a/web/src/hooks/logic-hooks.ts b/web/src/hooks/logic-hooks.ts
index 07a88738ed..92f1e9b184 100644
--- a/web/src/hooks/logic-hooks.ts
+++ b/web/src/hooks/logic-hooks.ts
@@ -437,7 +437,13 @@ export const useScrollToBottom = (
const container = containerRef.current;
const handleScroll = () => {
- setIsAtBottom(checkIfUserAtBottom());
+ // Write the ref here rather than relying on the effect that mirrors
+ // `isAtBottom`: that effect only runs after the next render, and while the
+ // main thread is busy rendering a streaming answer an already scheduled
+ // auto-scroll would still see the stale `true`.
+ const atBottom = checkIfUserAtBottom();
+ isAtBottomRef.current = atBottom;
+ setIsAtBottom(atBottom);
};
container.addEventListener('scroll', handleScroll);
@@ -456,16 +462,22 @@ export const useScrollToBottom = (
}
}, [containerRef]);
+ // Streaming replaces `messages` many times a second. The previous
+ // rAF + setTimeout(100) chain always had several scrolls queued, and they read
+ // `isAtBottomRef` long after the user had scrolled up — yanking the view back
+ // down. One cancellable frame per change, gated on the latest position, keeps
+ // auto-follow without fighting the user.
useEffect(() => {
if (!messages) return;
if (!containerRef?.current) return;
- requestAnimationFrame(() => {
- setTimeout(() => {
- if (isAtBottomRef.current) {
- scrollToBottom();
- }
- }, 100);
+ if (!isAtBottomRef.current) return;
+
+ const frame = requestAnimationFrame(() => {
+ if (isAtBottomRef.current) {
+ scrollToBottom();
+ }
});
+ return () => cancelAnimationFrame(frame);
}, [messages, containerRef, scrollToBottom]);
return { scrollRef: ref, isAtBottom, scrollToBottom };
diff --git a/web/src/pages/next-chats/chat/chat-box/next-multiple-chat-box.tsx b/web/src/pages/next-chats/chat/chat-box/next-multiple-chat-box.tsx
index 634404f5ff..7890950477 100644
--- a/web/src/pages/next-chats/chat/chat-box/next-multiple-chat-box.tsx
+++ b/web/src/pages/next-chats/chat/chat-box/next-multiple-chat-box.tsx
@@ -57,7 +57,8 @@ import {
UseSendSingleMessageParameter,
} from '../../hooks/use-send-single-message';
import { useUploadFile } from '../../hooks/use-upload-file';
-import { buildMessageItemReference } from '../../utils';
+import { useMessageReferences } from '../../hooks/use-message-references';
+import { EmptyReference } from '../../utils';
import { useAddChatBox } from '../use-add-box';
import { useShowInternet } from '../use-show-internet';
@@ -123,6 +124,11 @@ const ChatCard = forwardRef(function ChatCard(
const { scrollRef } = useScrollToBottom(derivedMessages, messageContainerRef);
+ const messageReferences = useMessageReferences(
+ derivedMessages,
+ conversation.reference,
+ );
+
const FormSchema = z.object(LlmSettingSchema);
const form = useForm
>({
@@ -166,10 +172,7 @@ const ChatCard = forwardRef(function ChatCard(
// conversation switch), not when currentDialog.llm_id changes due to Apply.
const syncedDialogIdRef = useRef(undefined);
useLayoutEffect(() => {
- if (
- syncedDialogIdRef.current !== dialogId &&
- currentDialog?.llm_id
- ) {
+ if (syncedDialogIdRef.current !== dialogId && currentDialog?.llm_id) {
form.setValue('llm_id', currentDialog.llm_id);
syncedDialogIdRef.current = dialogId;
}
@@ -290,13 +293,7 @@ const ChatCard = forwardRef(function ChatCard(
nickname={userInfo.nickname}
avatar={userInfo.avatar}
avatarDialog={currentDialog.icon}
- reference={buildMessageItemReference(
- {
- messages: derivedMessages,
- reference: conversation.reference,
- },
- message,
- )}
+ reference={messageReferences.get(message) ?? EmptyReference}
// clickDocumentButton={clickDocumentButton}
index={i}
removeMessageById={removeMessageById}
diff --git a/web/src/pages/next-chats/chat/chat-box/single-chat-box.tsx b/web/src/pages/next-chats/chat/chat-box/single-chat-box.tsx
index 22240846a0..dd1208d62b 100644
--- a/web/src/pages/next-chats/chat/chat-box/single-chat-box.tsx
+++ b/web/src/pages/next-chats/chat/chat-box/single-chat-box.tsx
@@ -14,7 +14,8 @@ import {
} from '../../hooks/use-button-disabled';
import { useCreateConversationBeforeUploadDocument } from '../../hooks/use-create-conversation';
import { useSendMessage } from '../../hooks/use-send-chat-message';
-import { buildMessageItemReference } from '../../utils';
+import { useMessageReferences } from '../../hooks/use-message-references';
+import { EmptyReference } from '../../utils';
import { useShowInternet } from '../use-show-internet';
interface IProps {
@@ -50,6 +51,11 @@ export function SingleChatBox({ conversation }: IProps) {
const showInternet = useShowInternet();
+ const messageReferences = useMessageReferences(
+ messages,
+ conversation.reference,
+ );
+
useEffect(() => {
// Skip when the conversation prop is stale — its id doesn't match the
// URL's current conversationId. This happens during a switch (e.g.
@@ -92,13 +98,7 @@ export function SingleChatBox({ conversation }: IProps) {
nickname={userInfo.nickname}
avatar={userInfo.avatar}
avatarDialog={currentDialog.icon}
- reference={buildMessageItemReference(
- {
- messages,
- reference: conversation.reference,
- },
- message,
- )}
+ reference={messageReferences.get(message) ?? EmptyReference}
clickDocumentButton={clickDocumentButton}
index={i}
removeMessageById={removeMessageById}
diff --git a/web/src/pages/next-chats/hooks/use-message-references.ts b/web/src/pages/next-chats/hooks/use-message-references.ts
new file mode 100644
index 0000000000..0d04d41e48
--- /dev/null
+++ b/web/src/pages/next-chats/hooks/use-message-references.ts
@@ -0,0 +1,71 @@
+import { MessageType } from '@/constants/chat';
+import { IMessage, IReference } from '@/interfaces/database/chat';
+import { isEmpty } from 'lodash';
+import { useMemo } from 'react';
+import { EmptyReference } from '../utils';
+
+/**
+ * Resolves the reference of every message in one pass and caches the result
+ * until the message list or the conversation's reference list changes.
+ *
+ * Doing this per message inside the render loop (the previous
+ * `buildMessageItemReference` call site) was quadratic, and — worse — handed
+ * `MessageItem` a freshly allocated object on every streaming flush, which
+ * defeated its `memo` and re-parsed the markdown of the whole transcript
+ * ~16 times a second.
+ *
+ * Keyed by message object rather than by id: a question and its answer share an
+ * id, so an id-keyed map could not tell them apart.
+ */
+export function useMessageReferences(
+ messages: IMessage[] | undefined,
+ reference: IReference[] | undefined,
+) {
+ return useMemo(() => {
+ const list = messages ?? [];
+ const references = reference ?? [];
+
+ // Index of each assistant message within the answer sequence, skipping the
+ // prologue (which has no reference) and error answers. First id wins, which
+ // is what the previous `findIndex` lookup did.
+ const answerIndexById = new Map();
+ let answerCount = 0;
+ list.forEach((message) => {
+ if (
+ message.role !== MessageType.Assistant ||
+ message.content?.startsWith('**ERROR**:')
+ ) {
+ return;
+ }
+ if (answerCount > 0 && !answerIndexById.has(message.id)) {
+ answerIndexById.set(message.id, answerCount - 1);
+ }
+ answerCount += 1;
+ });
+
+ const resolved = new Map();
+ list.forEach((message) => {
+ if (!isEmpty(message.reference)) {
+ resolved.set(message, message.reference as IReference);
+ return;
+ }
+ // An assistant message that has not received any content yet is still
+ // being generated. Never resolve its reference from the conversation
+ // reference list — the indices only align with completed answers, so the
+ // lookup would surface a stale reference from a previous turn (or from a
+ // previously opened conversation) while the answer is streaming.
+ if (message.role === MessageType.Assistant && !message.content) {
+ resolved.set(message, EmptyReference);
+ return;
+ }
+ const answerIndex = answerIndexById.get(message.id);
+ resolved.set(
+ message,
+ (answerIndex === undefined ? undefined : references[answerIndex]) ??
+ EmptyReference,
+ );
+ });
+
+ return resolved;
+ }, [messages, reference]);
+}
diff --git a/web/src/pages/next-chats/hooks/use-send-chat-message.ts b/web/src/pages/next-chats/hooks/use-send-chat-message.ts
index e406fa31d5..2bd339f220 100644
--- a/web/src/pages/next-chats/hooks/use-send-chat-message.ts
+++ b/web/src/pages/next-chats/hooks/use-send-chat-message.ts
@@ -98,19 +98,17 @@ export const useSendMessage = () => {
}: {
message: IMessage;
currentConversationId?: string;
- messages?: IMessage[];
+ messages: IMessage[];
} & NextMessageInputOnPressEnterParameter) => {
const sessionId = currentConversationId ?? conversationId;
const { ok, aborted } = await runChatCompletionStream({
conversationId: sessionId,
chatId,
- // An explicitly provided list is authoritative, even when empty
- // (e.g. regenerating the first question must truncate history).
- messages: [
- ...(Array.isArray(explicitMessages) ? explicitMessages : messages),
- message,
- ],
+ // The history is always supplied by the caller, captured *before* it
+ // appended the question and its placeholder to the store — reading it
+ // here would include both and send the question twice.
+ messages: [...explicitMessages, message],
enableThinking,
enableInternet,
});
@@ -124,7 +122,7 @@ export const useSendMessage = () => {
notification.error({ message: t('message.requestError') });
}
},
- [conversationId, chatId, messages, failStream, t],
+ [conversationId, chatId, failStream, t],
);
// Hand a failed question back to the input box, but only once the box is
@@ -156,8 +154,13 @@ export const useSendMessage = () => {
conversationId,
};
+ // Snapshot the history before appendQuestion writes the question and its
+ // assistant placeholder into the store.
+ const history =
+ useChatStreamStore.getState().sessions[conversationId]?.messages ?? [];
+
appendQuestion(conversationId, questionMessage);
- sendMessage({ message: questionMessage });
+ sendMessage({ message: questionMessage, messages: history });
},
[conversationId, isStreaming, appendQuestion, sendMessage],
);
@@ -217,6 +220,13 @@ export const useSendMessage = () => {
// Route the question to the conversation it was asked in, not whichever
// is currently displayed.
+ //
+ // Snapshot the history before appendQuestion writes the question and its
+ // assistant placeholder into the store.
+ const history =
+ useChatStreamStore.getState().sessions[targetConversationId]
+ ?.messages ?? [];
+
appendQuestion(targetConversationId, questionMessage);
setValue('');
@@ -224,7 +234,7 @@ export const useSendMessage = () => {
currentConversationId: targetConversationId,
// For an existing conversation currentMessages is empty; fall back to
// the store's message list instead of sending an empty history.
- messages: currentMessages.length > 0 ? currentMessages : undefined,
+ messages: currentMessages.length > 0 ? currentMessages : history,
message: {
id,
content: value.trim(),
diff --git a/web/src/pages/next-chats/share/index.tsx b/web/src/pages/next-chats/share/index.tsx
index e130342a45..6da219fb41 100644
--- a/web/src/pages/next-chats/share/index.tsx
+++ b/web/src/pages/next-chats/share/index.tsx
@@ -14,7 +14,8 @@ import {
useGetSharedChatSearchParams,
useSendSharedMessage,
} from '../hooks/use-send-shared-message';
-import { buildMessageItemReference } from '../utils';
+import { useMessageReferences } from '../hooks/use-message-references';
+import { EmptyReference } from '../utils';
const ChatContainer = () => {
const {
@@ -42,6 +43,8 @@ const ChatContainer = () => {
const sendDisabled = useSendButtonDisabled(value);
const { data: chatInfo } = useFetchExternalChatInfo();
+ const messageReferences = useMessageReferences(derivedMessages, undefined);
+
React.useEffect(() => {
if (locale && i18n.language !== locale) {
changeLanguageAsync(locale, { persist: false });
@@ -78,13 +81,7 @@ const ChatContainer = () => {
avatarDialog={avatarDialogSrc}
item={message}
nickname="You"
- reference={buildMessageItemReference(
- {
- messages: derivedMessages,
- reference: [],
- },
- message,
- )}
+ reference={messageReferences.get(message) ?? EmptyReference}
loading={
message.role === MessageType.Assistant &&
sendLoading &&
diff --git a/web/src/pages/next-chats/utils.ts b/web/src/pages/next-chats/utils.ts
index 6718bfb57b..d1a4dae2bb 100644
--- a/web/src/pages/next-chats/utils.ts
+++ b/web/src/pages/next-chats/utils.ts
@@ -1,10 +1,5 @@
-import { EmptyConversationId, MessageType } from '@/constants/chat';
-import {
- IConversation,
- IMessage,
- IReference,
-} from '@/interfaces/database/chat';
-import { isEmpty } from 'lodash';
+import { EmptyConversationId } from '@/constants/chat';
+import { IConversation, IReference } from '@/interfaces/database/chat';
export const isConversationIdExist = (conversationId: string) => {
return conversationId !== EmptyConversationId && conversationId !== '';
@@ -27,34 +22,11 @@ export const getDocumentIdsFromConversionReference = (data: IConversation) => {
return documentIds.join(',');
};
-export const buildMessageItemReference = (
- conversation: { messages: IMessage[]; reference: IReference[] },
- message: IMessage,
-) => {
- const assistantMessages = conversation.messages
- ?.filter(
- (x) =>
- x.role === MessageType.Assistant &&
- !x.content?.startsWith('**ERROR**:'), // Exclude error messages
- )
- .slice(1);
- const referenceIndex = assistantMessages.findIndex(
- (x) => x.id === message.id,
- );
- // An assistant message that has not received any content yet is still
- // being generated. Never resolve its reference from the conversation
- // reference list — the indices only align with completed answers, so the
- // lookup would surface a stale reference from a previous turn (or from a
- // previously opened conversation) while the answer is streaming.
- const isPendingAnswer =
- message.role === MessageType.Assistant &&
- !message.content &&
- isEmpty(message?.reference);
- const reference = !isEmpty(message?.reference)
- ? message?.reference
- : isPendingAnswer
- ? undefined
- : (conversation?.reference ?? [])[referenceIndex];
-
- return reference ?? { doc_aggs: [], chunks: [], total: 0 };
+// Shared fallback so a message without a reference keeps handing MessageItem the
+// same object across renders. A fresh literal here would break the item's memo
+// on every streaming flush. See useMessageReferences.
+export const EmptyReference: IReference = {
+ doc_aggs: [],
+ chunks: [],
+ total: 0,
};