Skip to content

Commit 66ba312

Browse files
authored
fix(web): suppress Mermaid render error SVGs (#813)
* test: reproduce issue #785 * fix: suppress Mermaid render errors (closes #785)
1 parent 3a8693f commit 66ba312

2 files changed

Lines changed: 87 additions & 16 deletions

File tree

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,57 @@
1-
import { describe, expect, it, vi } from 'vitest'
2-
import { render, waitFor } from '@testing-library/react'
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { cleanup, render, waitFor } from '@testing-library/react'
33

44
const mermaidMocks = vi.hoisted(() => ({
55
initializeMock: vi.fn(),
6-
renderMock: vi.fn().mockResolvedValue({
7-
svg: '<svg data-testid="mock-mermaid"></svg>'
8-
})
6+
parseMock: vi.fn(),
7+
renderMock: vi.fn(),
8+
setParseErrorHandlerMock: vi.fn(),
99
}))
1010

1111
vi.mock('mermaid', () => ({
1212
default: {
1313
initialize: mermaidMocks.initializeMock,
14+
parse: mermaidMocks.parseMock,
1415
render: mermaidMocks.renderMock,
16+
setParseErrorHandler: mermaidMocks.setParseErrorHandlerMock,
1517
}
1618
}))
1719

1820
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
1921
import { MARKDOWN_COMPONENTS_BY_LANGUAGE } from '@/components/assistant-ui/markdown-text'
2022

23+
function renderMermaid(code: string) {
24+
return render(
25+
<MermaidDiagram
26+
code={code}
27+
language="mermaid"
28+
components={{
29+
Pre: (props) => <pre {...props} />,
30+
Code: (props) => <code {...props} />,
31+
}}
32+
/>
33+
)
34+
}
35+
2136
describe('MermaidDiagram', () => {
37+
beforeEach(() => {
38+
mermaidMocks.initializeMock.mockClear()
39+
mermaidMocks.setParseErrorHandlerMock.mockClear()
40+
mermaidMocks.parseMock.mockReset()
41+
mermaidMocks.parseMock.mockResolvedValue({ diagramType: 'flowchart-v2' })
42+
mermaidMocks.renderMock.mockReset()
43+
mermaidMocks.renderMock.mockResolvedValue({
44+
svg: '<svg data-testid="mock-mermaid"></svg>'
45+
})
46+
})
47+
48+
afterEach(() => {
49+
cleanup()
50+
document.documentElement.removeAttribute('data-theme')
51+
})
52+
2253
it('is wired into the shared markdown language overrides and renders svg output', async () => {
23-
render(
24-
<MermaidDiagram
25-
code={'graph TD\nA --> B'}
26-
language="mermaid"
27-
components={{
28-
Pre: (props) => <pre {...props} />,
29-
Code: (props) => <code {...props} />,
30-
}}
31-
/>
32-
)
54+
renderMermaid('graph TD\nA --> B')
3355

3456
await waitFor(() => {
3557
const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
@@ -39,9 +61,47 @@ describe('MermaidDiagram', () => {
3961

4062
expect(mermaidMocks.initializeMock).toHaveBeenCalled()
4163
expect(mermaidMocks.initializeMock).toHaveBeenCalledWith(expect.objectContaining({
42-
securityLevel: 'strict'
64+
securityLevel: 'strict',
65+
suppressErrorRendering: true,
4366
}))
67+
expect(mermaidMocks.parseMock).toHaveBeenCalledWith('graph TD\nA --> B', { suppressErrors: true })
4468
expect(mermaidMocks.renderMock).toHaveBeenCalledWith(expect.stringContaining('mermaid-'), 'graph TD\nA --> B')
4569
expect(MARKDOWN_COMPONENTS_BY_LANGUAGE.mermaid.SyntaxHighlighter).toBe(MermaidDiagram)
4670
})
71+
72+
it('falls back to source and suppresses Mermaid parse-error side effects for invalid syntax', async () => {
73+
document.documentElement.dataset.theme = 'dark'
74+
mermaidMocks.parseMock.mockResolvedValueOnce(false)
75+
76+
renderMermaid('graph TD\nA --')
77+
78+
await waitFor(() => {
79+
const fallback = document.querySelector('.aui-mermaid-fallback')
80+
expect(fallback).toBeTruthy()
81+
expect(fallback?.textContent).toBe('graph TD\nA --')
82+
})
83+
84+
expect(mermaidMocks.parseMock).toHaveBeenCalledWith('graph TD\nA --', { suppressErrors: true })
85+
expect(mermaidMocks.renderMock).not.toHaveBeenCalled()
86+
expect(mermaidMocks.setParseErrorHandlerMock).toHaveBeenCalled()
87+
})
88+
89+
it('falls back to source and asks Mermaid not to inject its own error SVG when render throws', async () => {
90+
mermaidMocks.renderMock.mockRejectedValueOnce(new Error('render failed'))
91+
const code = 'gantt\ndateFormat YYYY-MM-DD\nsection A\nTask :a, 2024-01-01'
92+
93+
renderMermaid(code)
94+
95+
await waitFor(() => {
96+
const fallback = document.querySelector('.aui-mermaid-fallback')
97+
expect(fallback).toBeTruthy()
98+
expect(fallback?.textContent).toBe(code)
99+
})
100+
101+
expect(mermaidMocks.renderMock).toHaveBeenCalled()
102+
expect(mermaidMocks.initializeMock).toHaveBeenCalledWith(expect.objectContaining({
103+
suppressErrorRendering: true,
104+
}))
105+
})
106+
47107
})

web/src/components/assistant-ui/mermaid-diagram.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ async function ensureMermaid(theme: 'light' | 'dark') {
2121
const mermaid = await getMermaid()
2222
if (initializedTheme === theme) return mermaid
2323

24+
mermaid.setParseErrorHandler(() => undefined)
25+
2426
mermaid.initialize({
2527
startOnLoad: false,
2628
securityLevel: 'strict',
29+
suppressErrorRendering: true,
2730
theme: theme === 'dark' ? 'dark' : 'default',
2831
themeVariables: theme === 'dark'
2932
? {
@@ -101,6 +104,14 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
101104
const render = async () => {
102105
try {
103106
const mermaid = await ensureMermaid(theme)
107+
const isValid = await mermaid.parse(props.code, { suppressErrors: true })
108+
if (cancelled) return
109+
if (!isValid) {
110+
setSvg(null)
111+
setRenderError(true)
112+
return
113+
}
114+
104115
const result = await mermaid.render(`mermaid-${id}`, props.code)
105116
if (cancelled) return
106117
setSvg(result.svg)

0 commit comments

Comments
 (0)