diff --git a/web-app/src/containers/RenderMarkdown.tsx b/web-app/src/containers/RenderMarkdown.tsx index 27bec0ea2..da702eff6 100644 --- a/web-app/src/containers/RenderMarkdown.tsx +++ b/web-app/src/containers/RenderMarkdown.tsx @@ -7,7 +7,7 @@ import remarkBreaks from 'remark-breaks' import rehypeKatex from 'rehype-katex' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import * as prismStyles from 'react-syntax-highlighter/dist/cjs/styles/prism' -import { memo, useState, useMemo, useRef, useEffect } from 'react' +import { memo, useState, useMemo, useCallback } from 'react' import { getReadableLanguageName } from '@/lib/utils' import { cn } from '@/lib/utils' import { useCodeblock } from '@/hooks/useCodeblock' @@ -25,6 +25,177 @@ interface MarkdownProps { isWrapping?: boolean } +// Cache for normalized LaTeX content +const latexCache = new Map() + +/** + * Optimized preprocessor: normalize LaTeX fragments into $ / $$. + * Uses caching to avoid reprocessing the same content. + */ +const normalizeLatex = (input: string): string => { + // Check cache first + if (latexCache.has(input)) { + return latexCache.get(input)! + } + + const segments = input.split(/(```[\s\S]*?```|`[^`]*`|<[^>]+>)/g) + + const result = segments + .map((segment) => { + if (!segment) return '' + + // Skip code blocks, inline code, html tags + if (/^```[\s\S]*```$/.test(segment)) return segment + if (/^`[^`]*`$/.test(segment)) return segment + if (/^<[^>]+>$/.test(segment)) return segment + + let s = segment + + // --- Display math: \[...\] surrounded by newlines + s = s.replace( + /(^|\n)\\\[\s*\n([\s\S]*?)\n\s*\\\](?=\n|$)/g, + (_, pre, inner) => `${pre}$$\n${inner.trim()}\n$$` + ) + + // --- Inline math: space \( ... \) + s = s.replace( + /(^|[^$\\])\\\((.+?)\\\)(?=[^$\\]|$)/g, + (_, pre, inner) => `${pre}$${inner.trim()}$` + ) + + return s + }) + .join('') + + // Cache the result (with size limit to prevent memory leaks) + if (latexCache.size > 100) { + const firstKey = latexCache.keys().next().value || '' + latexCache.delete(firstKey) + } + latexCache.set(input, result) + + return result +} + +// Memoized code component to prevent unnecessary re-renders +const CodeComponent = memo( + ({ + className, + children, + isUser, + codeBlockStyle, + showLineNumbers, + isWrapping, + onCopy, + copiedId, + ...props + }: any) => { + const { t } = useTranslation() + const match = /language-(\w+)/.exec(className || '') + const language = match ? match[1] : '' + const isInline = !match || !language + + const code = String(children).replace(/\n$/, '') + + // Generate a stable ID based on content hash instead of position + const codeId = useMemo(() => { + let hash = 0 + for (let i = 0; i < code.length; i++) { + const char = code.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer + } + return `code-${Math.abs(hash)}-${language}` + }, [code, language]) + + const handleCopyClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onCopy(code, codeId) + }, + [code, codeId, onCopy] + ) + + if (isInline || isUser) { + return {children} + } + + return ( +
+ +
+ + {getReadableLanguageName(language)} + + +
+ + index === 0 + ? part + : part.charAt(0).toUpperCase() + part.slice(1) + ) + .join('') as keyof typeof prismStyles + ] || prismStyles.oneLight + } + language={language} + showLineNumbers={showLineNumbers} + wrapLines={true} + lineProps={ + isWrapping + ? { + style: { wordBreak: 'break-all', whiteSpace: 'pre-wrap' }, + } + : {} + } + customStyle={{ + margin: 0, + padding: '8px', + borderRadius: '0 0 4px 4px', + overflow: 'auto', + border: 'none', + }} + PreTag="div" + CodeTag={'code'} + {...props} + > + {code} + +
+ ) + } +) + +CodeComponent.displayName = 'CodeComponent' + function RenderMarkdownComponent({ content, enableRawHtml, @@ -33,21 +204,13 @@ function RenderMarkdownComponent({ components, isWrapping, }: MarkdownProps) { - const { t } = useTranslation() const { codeBlockStyle, showLineNumbers } = useCodeblock() // State for tracking which code block has been copied const [copiedId, setCopiedId] = useState(null) - // Map to store unique IDs for code blocks based on content and position - const codeBlockIds = useRef(new Map()) - // Clear ID map when content changes - useEffect(() => { - codeBlockIds.current.clear() - }, [content]) - - // Function to handle copying code to clipboard - const handleCopy = (code: string, id: string) => { + // Memoized copy handler + const handleCopy = useCallback((code: string, id: string) => { navigator.clipboard.writeText(code) setCopiedId(id) @@ -55,134 +218,51 @@ function RenderMarkdownComponent({ setTimeout(() => { setCopiedId(null) }, 2000) - } + }, []) - // Default components for syntax highlighting and emoji rendering - const defaultComponents: Components = useMemo( - () => ({ - code: ({ className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || '') - const language = match ? match[1] : '' - const isInline = !match || !language + // Memoize the normalized content to avoid reprocessing on every render + const normalizedContent = useMemo(() => normalizeLatex(content), [content]) - const code = String(children).replace(/\n$/, '') - - // Generate a unique ID based on content and language - const contentKey = `${code}-${language}` - let codeId = codeBlockIds.current.get(contentKey) - if (!codeId) { - codeId = `code-${codeBlockIds.current.size}` - codeBlockIds.current.set(contentKey, codeId) - } - - return !isInline && !isUser ? ( -
- -
- - {getReadableLanguageName(language)} - - -
- - index === 0 - ? part - : part.charAt(0).toUpperCase() + part.slice(1) - ) - .join('') as keyof typeof prismStyles - ] || prismStyles.oneLight - } - language={language} - showLineNumbers={showLineNumbers} - wrapLines={true} - // Temporary comment we try calculate main area width on __root - lineProps={ - isWrapping - ? { - style: { wordBreak: 'break-all', whiteSpace: 'pre-wrap' }, - } - : {} - } - customStyle={{ - margin: 0, - padding: '8px', - borderRadius: '0 0 4px 4px', - overflow: 'auto', - border: 'none', - }} - PreTag="div" - CodeTag={'code'} - {...props} - > - {String(children).replace(/\n$/, '')} - -
- ) : ( - {children} - ) - }, - }), - [codeBlockStyle, showLineNumbers, copiedId] - ) - - // Memoize the remarkPlugins to prevent unnecessary re-renders + // Stable remarkPlugins reference const remarkPlugins = useMemo(() => { - // Using a simpler configuration to avoid TypeScript errors const basePlugins = [remarkGfm, remarkMath, remarkEmoji] - // Add remark-breaks for user messages to handle single newlines as line breaks if (isUser) { basePlugins.push(remarkBreaks) } return basePlugins }, [isUser]) - // Memoize the rehypePlugins to prevent unnecessary re-renders + // Stable rehypePlugins reference const rehypePlugins = useMemo(() => { return enableRawHtml ? [rehypeKatex, rehypeRaw] : [rehypeKatex] }, [enableRawHtml]) - // Merge custom components with default components - const mergedComponents = useMemo( + // Memoized components with stable references + const markdownComponents: Components = useMemo( () => ({ - ...defaultComponents, + code: (props) => ( + + ), + // Add other optimized components if needed ...components, }), - [defaultComponents, components] + [ + isUser, + codeBlockStyle, + showLineNumbers, + isWrapping, + handleCopy, + copiedId, + components, + ] ) // Render the markdown content @@ -197,14 +277,14 @@ function RenderMarkdownComponent({ - {content} + {normalizedContent} ) } - -// Use a simple memo without custom comparison to allow re-renders when content changes -// This is important for streaming content to render incrementally -export const RenderMarkdown = memo(RenderMarkdownComponent) +export const RenderMarkdown = memo( + RenderMarkdownComponent, + (prevProps, nextProps) => prevProps.content === nextProps.content +)