@@ -7,7 +7,7 @@ import remarkBreaks from 'remark-breaks'
77import rehypeKatex from 'rehype-katex'
88import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
99import * as prismStyles from 'react-syntax-highlighter/dist/cjs/styles/prism'
10- import { memo , useState , useMemo , useRef , useEffect } from 'react'
10+ import { memo , useState , useMemo , useCallback } from 'react'
1111import { getReadableLanguageName } from '@/lib/utils'
1212import { cn } from '@/lib/utils'
1313import { useCodeblock } from '@/hooks/useCodeblock'
@@ -25,6 +25,177 @@ interface MarkdownProps {
2525 isWrapping ?: boolean
2626}
2727
28+ // Cache for normalized LaTeX content
29+ const latexCache = new Map < string , string > ( )
30+
31+ /**
32+ * Optimized preprocessor: normalize LaTeX fragments into $ / $$.
33+ * Uses caching to avoid reprocessing the same content.
34+ */
35+ const normalizeLatex = ( input : string ) : string => {
36+ // Check cache first
37+ if ( latexCache . has ( input ) ) {
38+ return latexCache . get ( input ) !
39+ }
40+
41+ const segments = input . split ( / ( ` ` ` [ \s \S ] * ?` ` ` | ` [ ^ ` ] * ` | < [ ^ > ] + > ) / g)
42+
43+ const result = segments
44+ . map ( ( segment ) => {
45+ if ( ! segment ) return ''
46+
47+ // Skip code blocks, inline code, html tags
48+ if ( / ^ ` ` ` [ \s \S ] * ` ` ` $ / . test ( segment ) ) return segment
49+ if ( / ^ ` [ ^ ` ] * ` $ / . test ( segment ) ) return segment
50+ if ( / ^ < [ ^ > ] + > $ / . test ( segment ) ) return segment
51+
52+ let s = segment
53+
54+ // --- Display math: \[...\] surrounded by newlines
55+ s = s . replace (
56+ / ( ^ | \n ) \\ \[ \s * \n ( [ \s \S ] * ?) \n \s * \\ \] (? = \n | $ ) / g,
57+ ( _ , pre , inner ) => `${ pre } $$\n${ inner . trim ( ) } \n$$`
58+ )
59+
60+ // --- Inline math: space \( ... \)
61+ s = s . replace (
62+ / ( ^ | [ ^ $ \\ ] ) \\ \( ( .+ ?) \\ \) (? = [ ^ $ \\ ] | $ ) / g,
63+ ( _ , pre , inner ) => `${ pre } $${ inner . trim ( ) } $`
64+ )
65+
66+ return s
67+ } )
68+ . join ( '' )
69+
70+ // Cache the result (with size limit to prevent memory leaks)
71+ if ( latexCache . size > 100 ) {
72+ const firstKey = latexCache . keys ( ) . next ( ) . value || ''
73+ latexCache . delete ( firstKey )
74+ }
75+ latexCache . set ( input , result )
76+
77+ return result
78+ }
79+
80+ // Memoized code component to prevent unnecessary re-renders
81+ const CodeComponent = memo (
82+ ( {
83+ className,
84+ children,
85+ isUser,
86+ codeBlockStyle,
87+ showLineNumbers,
88+ isWrapping,
89+ onCopy,
90+ copiedId,
91+ ...props
92+ } : any ) => {
93+ const { t } = useTranslation ( )
94+ const match = / l a n g u a g e - ( \w + ) / . exec ( className || '' )
95+ const language = match ? match [ 1 ] : ''
96+ const isInline = ! match || ! language
97+
98+ const code = String ( children ) . replace ( / \n $ / , '' )
99+
100+ // Generate a stable ID based on content hash instead of position
101+ const codeId = useMemo ( ( ) => {
102+ let hash = 0
103+ for ( let i = 0 ; i < code . length ; i ++ ) {
104+ const char = code . charCodeAt ( i )
105+ hash = ( hash << 5 ) - hash + char
106+ hash = hash & hash // Convert to 32-bit integer
107+ }
108+ return `code-${ Math . abs ( hash ) } -${ language } `
109+ } , [ code , language ] )
110+
111+ const handleCopyClick = useCallback (
112+ ( e : React . MouseEvent ) => {
113+ e . stopPropagation ( )
114+ onCopy ( code , codeId )
115+ } ,
116+ [ code , codeId , onCopy ]
117+ )
118+
119+ if ( isInline || isUser ) {
120+ return < code className = { cn ( className ) } > { children } </ code >
121+ }
122+
123+ return (
124+ < div className = "relative overflow-hidden border rounded-md border-main-view-fg/2" >
125+ < style >
126+ { `
127+ .react-syntax-highlighter-line-number {
128+ user-select: none;
129+ -webkit-user-select: none;
130+ -moz-user-select: none;
131+ -ms-user-select: none;
132+ }
133+ ` }
134+ </ style >
135+ < div className = "flex items-center justify-between px-4 py-2 bg-main-view/10" >
136+ < span className = "font-medium text-xs font-sans" >
137+ { getReadableLanguageName ( language ) }
138+ </ span >
139+ < button
140+ onClick = { handleCopyClick }
141+ className = "flex items-center gap-1 text-xs font-sans transition-colors cursor-pointer"
142+ >
143+ { copiedId === codeId ? (
144+ < >
145+ < IconCopyCheck size = { 16 } className = "text-primary" />
146+ < span > { t ( 'copied' ) } </ span >
147+ </ >
148+ ) : (
149+ < >
150+ < IconCopy size = { 16 } />
151+ < span > { t ( 'copy' ) } </ span >
152+ </ >
153+ ) }
154+ </ button >
155+ </ div >
156+ < SyntaxHighlighter
157+ style = {
158+ prismStyles [
159+ codeBlockStyle
160+ . split ( '-' )
161+ . map ( ( part : string , index : number ) =>
162+ index === 0
163+ ? part
164+ : part . charAt ( 0 ) . toUpperCase ( ) + part . slice ( 1 )
165+ )
166+ . join ( '' ) as keyof typeof prismStyles
167+ ] || prismStyles . oneLight
168+ }
169+ language = { language }
170+ showLineNumbers = { showLineNumbers }
171+ wrapLines = { true }
172+ lineProps = {
173+ isWrapping
174+ ? {
175+ style : { wordBreak : 'break-all' , whiteSpace : 'pre-wrap' } ,
176+ }
177+ : { }
178+ }
179+ customStyle = { {
180+ margin : 0 ,
181+ padding : '8px' ,
182+ borderRadius : '0 0 4px 4px' ,
183+ overflow : 'auto' ,
184+ border : 'none' ,
185+ } }
186+ PreTag = "div"
187+ CodeTag = { 'code' }
188+ { ...props }
189+ >
190+ { code }
191+ </ SyntaxHighlighter >
192+ </ div >
193+ )
194+ }
195+ )
196+
197+ CodeComponent . displayName = 'CodeComponent'
198+
28199function RenderMarkdownComponent ( {
29200 content,
30201 enableRawHtml,
@@ -33,156 +204,65 @@ function RenderMarkdownComponent({
33204 components,
34205 isWrapping,
35206} : MarkdownProps ) {
36- const { t } = useTranslation ( )
37207 const { codeBlockStyle, showLineNumbers } = useCodeblock ( )
38208
39209 // State for tracking which code block has been copied
40210 const [ copiedId , setCopiedId ] = useState < string | null > ( null )
41- // Map to store unique IDs for code blocks based on content and position
42- const codeBlockIds = useRef ( new Map < string , string > ( ) )
43-
44- // Clear ID map when content changes
45- useEffect ( ( ) => {
46- codeBlockIds . current . clear ( )
47- } , [ content ] )
48211
49- // Function to handle copying code to clipboard
50- const handleCopy = ( code : string , id : string ) => {
212+ // Memoized copy handler
213+ const handleCopy = useCallback ( ( code : string , id : string ) => {
51214 navigator . clipboard . writeText ( code )
52215 setCopiedId ( id )
53216
54217 // Reset copied state after 2 seconds
55218 setTimeout ( ( ) => {
56219 setCopiedId ( null )
57220 } , 2000 )
58- }
59-
60- // Default components for syntax highlighting and emoji rendering
61- const defaultComponents : Components = useMemo (
62- ( ) => ( {
63- code : ( { className, children, ...props } ) => {
64- const match = / l a n g u a g e - ( \w + ) / . exec ( className || '' )
65- const language = match ? match [ 1 ] : ''
66- const isInline = ! match || ! language
67-
68- const code = String ( children ) . replace ( / \n $ / , '' )
69-
70- // Generate a unique ID based on content and language
71- const contentKey = `${ code } -${ language } `
72- let codeId = codeBlockIds . current . get ( contentKey )
73- if ( ! codeId ) {
74- codeId = `code-${ codeBlockIds . current . size } `
75- codeBlockIds . current . set ( contentKey , codeId )
76- }
221+ } , [ ] )
77222
78- return ! isInline && ! isUser ? (
79- < div className = "relative overflow-hidden border rounded-md border-main-view-fg/2" >
80- < style >
81- { /* Disable selection of line numbers. React Syntax Highlighter currently has
82- unfixed bug so we can't use the lineNumberContainerStyleProp */ }
83- { `
84- .react-syntax-highlighter-line-number {
85- user-select: none;
86- -webkit-user-select: none;
87- -moz-user-select: none;
88- -ms-user-select: none;
89- }
90- ` }
91- </ style >
92- < div className = "flex items-center justify-between px-4 py-2 bg-main-view/10" >
93- < span className = "font-medium text-xs font-sans" >
94- { getReadableLanguageName ( language ) }
95- </ span >
96- < button
97- onClick = { ( e ) => {
98- e . stopPropagation ( )
99- handleCopy ( code , codeId )
100- } }
101- className = "flex items-center gap-1 text-xs font-sans transition-colors cursor-pointer"
102- >
103- { copiedId === codeId ? (
104- < >
105- < IconCopyCheck size = { 16 } className = "text-primary" />
106- < span > { t ( 'copied' ) } </ span >
107- </ >
108- ) : (
109- < >
110- < IconCopy size = { 16 } />
111- < span > { t ( 'copy' ) } </ span >
112- </ >
113- ) }
114- </ button >
115- </ div >
116- < SyntaxHighlighter
117- // @ts -expect-error - Type issues with style prop in react-syntax-highlighter
118- style = {
119- prismStyles [
120- codeBlockStyle
121- . split ( '-' )
122- . map ( ( part : string , index : number ) =>
123- index === 0
124- ? part
125- : part . charAt ( 0 ) . toUpperCase ( ) + part . slice ( 1 )
126- )
127- . join ( '' ) as keyof typeof prismStyles
128- ] || prismStyles . oneLight
129- }
130- language = { language }
131- showLineNumbers = { showLineNumbers }
132- wrapLines = { true }
133- // Temporary comment we try calculate main area width on __root
134- lineProps = {
135- isWrapping
136- ? {
137- style : { wordBreak : 'break-all' , whiteSpace : 'pre-wrap' } ,
138- }
139- : { }
140- }
141- customStyle = { {
142- margin : 0 ,
143- padding : '8px' ,
144- borderRadius : '0 0 4px 4px' ,
145- overflow : 'auto' ,
146- border : 'none' ,
147- } }
148- PreTag = "div"
149- CodeTag = { 'code' }
150- { ...props }
151- >
152- { String ( children ) . replace ( / \n $ / , '' ) }
153- </ SyntaxHighlighter >
154- </ div >
155- ) : (
156- < code className = { cn ( className ) } > { children } </ code >
157- )
158- } ,
159- } ) ,
160- [ codeBlockStyle , showLineNumbers , copiedId ]
161- )
223+ // Memoize the normalized content to avoid reprocessing on every render
224+ const normalizedContent = useMemo ( ( ) => normalizeLatex ( content ) , [ content ] )
162225
163- // Memoize the remarkPlugins to prevent unnecessary re-renders
226+ // Stable remarkPlugins reference
164227 const remarkPlugins = useMemo ( ( ) => {
165- // Using a simpler configuration to avoid TypeScript errors
166228 const basePlugins = [ remarkGfm , remarkMath , remarkEmoji ]
167- // Add remark-breaks for user messages to handle single newlines as line breaks
168229 if ( isUser ) {
169230 basePlugins . push ( remarkBreaks )
170231 }
171232 return basePlugins
172233 } , [ isUser ] )
173234
174- // Memoize the rehypePlugins to prevent unnecessary re-renders
235+ // Stable rehypePlugins reference
175236 const rehypePlugins = useMemo ( ( ) => {
176237 return enableRawHtml ? [ rehypeKatex , rehypeRaw ] : [ rehypeKatex ]
177238 } , [ enableRawHtml ] )
178239
179- // Merge custom components with default components
180- const mergedComponents = useMemo (
240+ // Memoized components with stable references
241+ const markdownComponents : Components = useMemo (
181242 ( ) => ( {
182- ...defaultComponents ,
243+ code : ( props ) => (
244+ < CodeComponent
245+ { ...props }
246+ isUser = { isUser }
247+ codeBlockStyle = { codeBlockStyle }
248+ showLineNumbers = { showLineNumbers }
249+ isWrapping = { isWrapping }
250+ onCopy = { handleCopy }
251+ copiedId = { copiedId }
252+ />
253+ ) ,
254+ // Add other optimized components if needed
183255 ...components ,
184256 } ) ,
185- [ defaultComponents , components ]
257+ [
258+ isUser ,
259+ codeBlockStyle ,
260+ showLineNumbers ,
261+ isWrapping ,
262+ handleCopy ,
263+ copiedId ,
264+ components ,
265+ ]
186266 )
187267
188268 // Render the markdown content
@@ -197,14 +277,14 @@ function RenderMarkdownComponent({
197277 < ReactMarkdown
198278 remarkPlugins = { remarkPlugins }
199279 rehypePlugins = { rehypePlugins }
200- components = { mergedComponents }
280+ components = { markdownComponents }
201281 >
202- { content }
282+ { normalizedContent }
203283 </ ReactMarkdown >
204284 </ div >
205285 )
206286}
207-
208- // Use a simple memo without custom comparison to allow re-renders when content changes
209- // This is important for streaming content to render incrementally
210- export const RenderMarkdown = memo ( RenderMarkdownComponent )
287+ export const RenderMarkdown = memo (
288+ RenderMarkdownComponent ,
289+ ( prevProps , nextProps ) => prevProps . content === nextProps . content
290+ )
0 commit comments