feat(mermaid): Rearchitect component for robustness, security, and theming (#21281)

This commit is contained in:
sayThQ199 2025-06-20 19:55:58 +08:00 • committed by GitHub
commit ba5eebf3a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 196 additions and 250 deletions

View file

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import mermaid from 'mermaid'
import mermaid, { type MermaidConfig } from 'mermaid'
import { useTranslation } from 'react-i18next'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
@ -68,14 +68,13 @@ const THEMES = {
const initMermaid = () => {
if (typeof window !== 'undefined' && !isMermaidInitialized) {
try {
mermaid.initialize({
const config: MermaidConfig = {
startOnLoad: false,
fontFamily: 'sans-serif',
securityLevel: 'loose',
flowchart: {
htmlLabels: true,
useMaxWidth: true,
diagramPadding: 10,
curve: 'basis',
nodeSpacing: 50,
rankSpacing: 70,
@ -94,10 +93,10 @@ const initMermaid = () => {
mindmap: {
useMaxWidth: true,
padding: 10,
diagramPadding: 20,
},
maxTextSize: 50000,
})
}
mermaid.initialize(config)
isMermaidInitialized = true
}
catch (error) {
@ -113,7 +112,7 @@ const Flowchart = React.forwardRef((props: {
theme?: 'light' | 'dark'
}, ref) => {
const { t } = useTranslation()
const [svgCode, setSvgCode] = useState<string | null>(null)
const [svgString, setSvgString] = useState<string | null>(null)
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
const [isInitialized, setIsInitialized] = useState(false)
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
@ -125,6 +124,7 @@ const Flowchart = React.forwardRef((props: {
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const [isCodeComplete, setIsCodeComplete] = useState(false)
const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
const prevCodeRef = useRef<string>()
// Create cache key from code, style and theme
const cacheKey = useMemo(() => {
@ -169,50 +169,18 @@ const Flowchart = React.forwardRef((props: {
*/
const handleRenderError = (error: any) => {
console.error('Mermaid rendering error:', error)
const errorMsg = (error as Error).message
if (errorMsg.includes('getAttribute')) {
diagramCache.clear()
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
})
// On any render error, assume the mermaid state is corrupted and force a re-initialization.
try {
diagramCache.clear() // Clear cache to prevent using potentially corrupted SVGs
isMermaidInitialized = false // <-- THE FIX: Force re-initialization
initMermaid() // Re-initialize with the default safe configuration
}
else {
setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`)
}
if (look === 'handDrawn') {
try {
// Clear possible cache issues
diagramCache.delete(`${props.PrimitiveCode}-handDrawn-${currentTheme}`)
// Reset mermaid configuration
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: 'default',
maxTextSize: 50000,
})
// Try rendering with standard mode
setLook('classic')
setErrMsg('Hand-drawn mode is not supported for this diagram. Switched to classic mode.')
// Delay error clearing
setTimeout(() => {
if (containerRef.current) {
// Try rendering again with standard mode, but can't call renderFlowchart directly due to circular dependency
// Instead set state to trigger re-render
setIsCodeComplete(true) // This will trigger useEffect re-render
}
}, 500)
}
catch (e) {
console.error('Reset after handDrawn error failed:', e)
}
catch (reinitError) {
console.error('Failed to re-initialize Mermaid after error:', reinitError)
}
setErrMsg(`Rendering failed: ${(error as Error).message || 'Unknown error. Please check the console.'}`)
setIsLoading(false)
}
@ -223,51 +191,23 @@ const Flowchart = React.forwardRef((props: {
setIsInitialized(true)
}, [])
// Update theme when prop changes
// Update theme when prop changes, but allow internal override.
const prevThemeRef = useRef<string>()
useEffect(() => {
if (props.theme)
// Only react if the theme prop from the outside has actually changed.
if (props.theme && props.theme !== prevThemeRef.current) {
// When the global theme prop changes, it should act as the source of truth,
// overriding any local theme selection.
diagramCache.clear()
setSvgString(null)
setCurrentTheme(props.theme)
// Reset look to classic for a consistent state after a global change.
setLook('classic')
}
// Update the ref to the current prop value for the next render.
prevThemeRef.current = props.theme
}, [props.theme])
// Validate mermaid code and check for completeness
useEffect(() => {
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
// Reset code complete status when code changes
setIsCodeComplete(false)
// If no code or code is extremely short, don't proceed
if (!props.PrimitiveCode || props.PrimitiveCode.length < 10)
return
// Check if code already in cache - if so we know it's valid
if (diagramCache.has(cacheKey)) {
setIsCodeComplete(true)
return
}
// Initial check using the extracted isMermaidCodeComplete function
const isComplete = isMermaidCodeComplete(props.PrimitiveCode)
if (isComplete) {
setIsCodeComplete(true)
return
}
// Set a delay to check again in case code is still being generated
codeCompletionCheckRef.current = setTimeout(() => {
setIsCodeComplete(isMermaidCodeComplete(props.PrimitiveCode))
}, 300)
return () => {
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
}
}, [props.PrimitiveCode, cacheKey])
/**
* Renders flowchart based on provided code
*/
const renderFlowchart = useCallback(async (primitiveCode: string) => {
if (!isInitialized || !containerRef.current) {
setIsLoading(false)
@ -275,15 +215,11 @@ const Flowchart = React.forwardRef((props: {
return
}
// Don't render if code is not complete yet
if (!isCodeComplete) {
setIsLoading(true)
return
}
// Return cached result if available
const cacheKey = `${primitiveCode}-${look}-${currentTheme}`
if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null)
setErrMsg('')
setSvgString(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
@ -294,17 +230,45 @@ const Flowchart = React.forwardRef((props: {
try {
let finalCode: string
// Check if it's a gantt chart or mindmap
const isGanttChart = primitiveCode.trim().startsWith('gantt')
const isMindMap = primitiveCode.trim().startsWith('mindmap')
const trimmedCode = primitiveCode.trim()
const isGantt = trimmedCode.startsWith('gantt')
const isMindMap = trimmedCode.startsWith('mindmap')
const isSequence = trimmedCode.startsWith('sequenceDiagram')
if (isGanttChart || isMindMap) {
// For gantt charts and mindmaps, ensure each task is on its own line
// and preserve exact whitespace/format
finalCode = primitiveCode.trim()
if (isGantt || isMindMap || isSequence) {
if (isGantt) {
finalCode = trimmedCode
.split('\n')
.map((line) => {
// Gantt charts have specific syntax needs.
const taskMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)/)
if (!taskMatch)
return line // Not a task line, return as is.
const taskName = taskMatch[1].trim()
let paramsStr = taskMatch[2].trim()
// Rule 1: Correct multiple "after" dependencies ONLY if they exist.
// This is a common mistake, e.g., "..., after task1, after task2, ..."
const afterCount = (paramsStr.match(/after /g) || []).length
if (afterCount > 1)
paramsStr = paramsStr.replace(/,\s*after\s+/g, ' ')
// Rule 2: Normalize spacing between parameters for consistency.
const finalParams = paramsStr.replace(/\s*,\s*/g, ', ').trim()
return `${taskName} :${finalParams}`
})
.join('\n')
}
else {
// For mindmap and sequence charts, which are sensitive to syntax,
// pass the code through directly.
finalCode = trimmedCode
}
}
else {
// Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
// This function handles flowcharts appropriately.
finalCode = prepareMermaidCode(primitiveCode, look)
}
@ -319,13 +283,12 @@ const Flowchart = React.forwardRef((props: {
THEMES,
)
// Step 4: Clean SVG code and convert to base64 using the extracted functions
// Step 4: Clean up SVG code
const cleanedSvg = cleanUpSvgCode(processedSvg)
const base64Svg = await svgToBase64(cleanedSvg)
if (base64Svg && typeof base64Svg === 'string') {
diagramCache.set(cacheKey, base64Svg)
setSvgCode(base64Svg)
if (cleanedSvg && typeof cleanedSvg === 'string') {
diagramCache.set(cacheKey, cleanedSvg)
setSvgString(cleanedSvg)
}
setIsLoading(false)
@ -334,12 +297,9 @@ const Flowchart = React.forwardRef((props: {
// Error handling
handleRenderError(error)
}
}, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
}, [chartId, isInitialized, look, currentTheme, t])
/**
* Configure mermaid based on selected style and theme
*/
const configureMermaid = useCallback(() => {
const configureMermaid = useCallback((primitiveCode: string) => {
if (typeof window !== 'undefined' && isInitialized) {
const themeVars = THEMES[currentTheme]
const config: any = {
@ -361,23 +321,37 @@ const Flowchart = React.forwardRef((props: {
mindmap: {
useMaxWidth: true,
padding: 10,
diagramPadding: 20,
},
}
const isFlowchart = primitiveCode.trim().startsWith('graph') || primitiveCode.trim().startsWith('flowchart')
if (look === 'classic') {
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
config.flowchart = {
htmlLabels: true,
useMaxWidth: true,
diagramPadding: 12,
nodeSpacing: 60,
rankSpacing: 80,
curve: 'linear',
ranker: 'tight-tree',
if (isFlowchart) {
config.flowchart = {
htmlLabels: true,
useMaxWidth: true,
nodeSpacing: 60,
rankSpacing: 80,
curve: 'linear',
ranker: 'tight-tree',
}
}
if (currentTheme === 'dark') {
config.themeVariables = {
background: themeVars.background,
primaryColor: themeVars.primaryColor,
primaryBorderColor: themeVars.primaryBorderColor,
primaryTextColor: themeVars.primaryTextColor,
secondaryColor: themeVars.secondaryColor,
tertiaryColor: themeVars.tertiaryColor,
}
}
}
else {
else { // look === 'handDrawn'
config.theme = 'default'
config.themeCSS = `
.node rect { fill-opacity: 0.85; }
@ -389,27 +363,17 @@ const Flowchart = React.forwardRef((props: {
config.themeVariables = {
fontSize: '14px',
fontFamily: 'sans-serif',
primaryBorderColor: currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor,
}
config.flowchart = {
htmlLabels: true,
useMaxWidth: true,
diagramPadding: 10,
nodeSpacing: 40,
rankSpacing: 60,
curve: 'basis',
}
config.themeVariables.primaryBorderColor = currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor
}
if (currentTheme === 'dark' && !config.themeVariables) {
config.themeVariables = {
background: themeVars.background,
primaryColor: themeVars.primaryColor,
primaryBorderColor: themeVars.primaryBorderColor,
primaryTextColor: themeVars.primaryTextColor,
secondaryColor: themeVars.secondaryColor,
tertiaryColor: themeVars.tertiaryColor,
fontFamily: 'sans-serif',
if (isFlowchart) {
config.flowchart = {
htmlLabels: true,
useMaxWidth: true,
nodeSpacing: 40,
rankSpacing: 60,
curve: 'basis',
}
}
}
@ -425,44 +389,50 @@ const Flowchart = React.forwardRef((props: {
return false
}, [currentTheme, isInitialized, look])
// Effect for theme and style configuration
// This is the main rendering effect.
// It triggers whenever the code, theme, or style changes.
useEffect(() => {
if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
if (configureMermaid() && containerRef.current && isCodeComplete)
renderFlowchart(props.PrimitiveCode)
}, [look, props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, currentTheme, isCodeComplete, configureMermaid])
// Effect for rendering with debounce
useEffect(() => {
if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null)
if (!isInitialized)
return
// Don't render if code is too short
if (!props.PrimitiveCode || props.PrimitiveCode.length < 10) {
setIsLoading(false)
setSvgString(null)
return
}
// Use a timeout to handle streaming code and debounce rendering
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
if (isCodeComplete) {
renderTimeoutRef.current = setTimeout(() => {
if (isInitialized)
renderFlowchart(props.PrimitiveCode)
}, 300)
}
else {
setIsLoading(true)
}
setIsLoading(true)
renderTimeoutRef.current = setTimeout(() => {
// Final validation before rendering
if (!isMermaidCodeComplete(props.PrimitiveCode)) {
setIsLoading(false)
setErrMsg('Diagram code is not complete or invalid.')
return
}
const cacheKey = `${props.PrimitiveCode}-${look}-${currentTheme}`
if (diagramCache.has(cacheKey)) {
setErrMsg('')
setSvgString(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
if (configureMermaid(props.PrimitiveCode))
renderFlowchart(props.PrimitiveCode)
}, 300) // 300ms debounce
return () => {
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
}
}, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete])
}, [props.PrimitiveCode, look, currentTheme, isInitialized, configureMermaid, renderFlowchart])
// Cleanup on unmount
useEffect(() => {
@ -471,14 +441,22 @@ const Flowchart = React.forwardRef((props: {
containerRef.current.innerHTML = ''
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
}
}, [])
const handlePreviewClick = async () => {
if (svgString) {
const base64 = await svgToBase64(svgString)
setImagePreviewUrl(base64)
}
}
const toggleTheme = () => {
setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light)
const newTheme = currentTheme === 'light' ? 'dark' : 'light'
// Ensure a full, clean re-render cycle, consistent with global theme change.
diagramCache.clear()
setSvgString(null)
setCurrentTheme(newTheme)
}
// Style classes for theme-dependent elements
@ -527,14 +505,26 @@ const Flowchart = React.forwardRef((props: {
<div
key='classic'
className={getLookButtonClass('classic')}
onClick={() => setLook('classic')}
onClick={() => {
if (look !== 'classic') {
diagramCache.clear()
setSvgString(null)
setLook('classic')
}
}}
>
<div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
</div>
<div
key='handDrawn'
className={getLookButtonClass('handDrawn')}
onClick={() => setLook('handDrawn')}
onClick={() => {
if (look !== 'handDrawn') {
diagramCache.clear()
setSvgString(null)
setLook('handDrawn')
}
}}
>
<div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
</div>
@ -544,7 +534,7 @@ const Flowchart = React.forwardRef((props: {
<div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
{isLoading && !svgCode && (
{isLoading && !svgString && (
<div className='px-[26px] py-4'>
<LoadingAnim type='text'/>
{!isCodeComplete && (
@ -555,8 +545,8 @@ const Flowchart = React.forwardRef((props: {
</div>
)}
{svgCode && (
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
{svgString && (
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={handlePreviewClick}>
<div className="absolute bottom-2 left-2 z-[100]">
<button
onClick={(e) => {
@ -571,11 +561,9 @@ const Flowchart = React.forwardRef((props: {
</button>
</div>
<img
src={svgCode}
alt="mermaid_chart"
<div
style={{ maxWidth: '100%' }}
onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }}
dangerouslySetInnerHTML={{ __html: svgString }}
/>
</div>
)}