Skip to content

Commit 38ad8de

Browse files
feat: normalize LaTeX fragments in markdown rendering (#6488)
* feat: normalize LaTeX fragments in markdown rendering Added a preprocessing step that converts LaTeX delimiters `\[…\]` to `$$…$$` and `\(...\)` to `$…$` before rendering. The function skips code blocks, inline code, and HTML tags to avoid unintended transformations. This improves authoring experience by supporting common LaTeX syntax without requiring explicit `$` delimiters. * fix: correct inline LaTeX normalization replacement The replacement function for inline math (`\(...\)`) incorrectly accepted a fourth parameter (`post`) and appended it to the result, which could introduce stray characters or `undefined` into the rendered output. Updated the function to use only the captured prefix and inner content and removed the extraneous `${post}` interpolation, ensuring clean LaTeX conversion. * feat: optimize markdown rendering with LaTeX caching and memoized code blocks - Added cache to normalizeLatex to avoid reprocessing repeated content - Introduced CodeComponent with stable IDs and memoization to reduce re-renders - Replaced per-render code block ID mapping with hash-based IDs - Memoized copy handler and normalized markdown content - Simplified plugin/component setup with stable references - Added custom comparison for RenderMarkdown memoization to prevent unnecessary updates * refactor: memoize content only --------- Co-authored-by: Louis <[email protected]>
1 parent c46e13b commit 38ad8de

File tree

1 file changed

+208
-128
lines changed

1 file changed

+208
-128
lines changed

web-app/src/containers/RenderMarkdown.tsx

Lines changed: 208 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import remarkBreaks from 'remark-breaks'
77
import rehypeKatex from 'rehype-katex'
88
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
99
import * 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'
1111
import { getReadableLanguageName } from '@/lib/utils'
1212
import { cn } from '@/lib/utils'
1313
import { 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 = /language-(\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+
28199
function 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 = /language-(\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

Comments
 (0)