fix(MermaidViewer): clean up orphan error SVG left in document.body#184
Conversation
When mermaid 11.13.0 fails to parse a diagram, errorRenderer.draw() paints
a large error icon SVG into the temporary `d{id}` wrapper that mermaid
appends to document.body, then immediately re-throws — skipping the
trailing removeTempElements() call (mermaid.core.mjs:1078-1110). The
orphan SVG (viewBox 0 0 2412 512) accumulates at the bottom of the page
on every failed render.
Add a cleanupOrphans() helper in the render effect that removes the
`d{id}` wrapper div and the `{id}` SVG node, invoked from a try/finally
around mermaid.render() and from the effect cleanup. The component's
own error UI is unchanged — failures still surface as a red banner with
the original code inside the card.
There was a problem hiding this comment.
Code Review
This pull request introduces a cleanup mechanism to remove orphaned DOM elements left by Mermaid after failed rendering attempts. The feedback highlights a potential race condition when using a stable ID across render attempts and suggests adding a unique suffix for isolation. Additionally, there is a concern that the cleanup logic might inadvertently remove the active diagram from the UI, so it is recommended to restrict element removal to those directly attached to the document body.
| // Render mermaid diagram | ||
| useEffect(() => { | ||
| let cancelled = false; | ||
| const id = `mermaid-${uniqueId}`; |
There was a problem hiding this comment.
Using a stable id across multiple render attempts can lead to race conditions. If a new render starts before a previous one finishes, they will both attempt to use the same DOM ID, which can cause Mermaid to fail or behave unexpectedly.
Consider appending a unique suffix to the id for each render attempt to ensure isolation.
| const id = `mermaid-${uniqueId}`; | |
| const id = `mermaid-${uniqueId}-${Math.random().toString(36).slice(2, 9)}`; |
| const cleanupOrphans = (): void => { | ||
| document.getElementById(`d${id}`)?.remove(); | ||
| document.getElementById(id)?.remove(); | ||
| }; |
There was a problem hiding this comment.
The current cleanupOrphans implementation might inadvertently remove the active diagram from the UI. When React renders the SVG into the component's tree, it will have the same id. If cleanupOrphans is called (e.g., during effect cleanup when the code changes), it will find the SVG in the UI and remove it from the DOM, causing the diagram to disappear prematurely.
To prevent this, ensure that only elements directly attached to document.body (Mermaid's temporary orphans) are removed.
const cleanupOrphans = (): void => {
const wrapper = document.getElementById(`d${id}`);
if (wrapper?.parentElement === document.body) wrapper.remove();
const svg = document.getElementById(id);
if (svg?.parentElement === document.body) svg.remove();
};
📝 WalkthroughWalkthroughThe MermaidViewer component's render effect now defines a stable diagram Changes
Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/renderer/components/chat/viewers/MermaidViewer.tsx (1)
75-97: Possible race between overlapping renders that share the sameid.
uniqueIdis stable for the component instance, so whencodeorisDarkchanges while a previousrender()is still in flight, both invocations callmermaid.render(id, …)with the sameid. Sequence to consider:
- Render A is awaiting
mermaid.render.- Effect re-runs (deps change) → teardown sets
cancelled = trueand callscleanupOrphans().- Render B starts and
mermaid.renderappends a freshd${id}wrapper todocument.body.- Render A finally settles → its
finallyrunscleanupOrphans()and yanks Render B's live wrapper out from under it.In practice this window is small and only matters under rapid prop changes, but it's the kind of thing that can produce a flaky empty/error state. Two cheap mitigations:
♻️ Option 1 — guard the finally with the cancelled flag
} finally { - cleanupOrphans(); + // Only the active render owns these orphans; a stale (cancelled) render + // could otherwise remove the next attempt's in-flight temp nodes. + if (!cancelled) cleanupOrphans(); }Note: the effect-teardown call at line 96 still ensures cleanup on unmount / dep change.
♻️ Option 2 — use a per-attempt id
Generate a fresh suffix per
render()invocation so concurrent attempts don't collide, and have each invocation clean up only its own id.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/renderer/components/chat/viewers/MermaidViewer.tsx` around lines 75 - 97, The race is caused by concurrent render() calls sharing the stable component-wide id; change render() to use a fresh per-attempt id/suffix (e.g. attemptId or renderSuffix) created inside the render function and pass that composed id to m.render(...) so each invocation renders into a unique wrapper, and adjust cleanupOrphans()/cleanup call inside render() to only remove the wrapper for that attemptId (leave the effect teardown to still set cancelled = true and perform global cleanup). Update references: render (the async function), id (component id), ensureMermaidInit, cleanupOrphans, setSvg and setError so each attempt only cleans up its own DOM node and does not remove other in-flight renderers' wrappers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/renderer/components/chat/viewers/MermaidViewer.tsx`:
- Around line 75-97: The race is caused by concurrent render() calls sharing the
stable component-wide id; change render() to use a fresh per-attempt id/suffix
(e.g. attemptId or renderSuffix) created inside the render function and pass
that composed id to m.render(...) so each invocation renders into a unique
wrapper, and adjust cleanupOrphans()/cleanup call inside render() to only remove
the wrapper for that attemptId (leave the effect teardown to still set cancelled
= true and perform global cleanup). Update references: render (the async
function), id (component id), ensureMermaidInit, cleanupOrphans, setSvg and
setError so each attempt only cleans up its own DOM node and does not remove
other in-flight renderers' wrappers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1fc1be47-7a3e-496a-9262-26c236a012a1
📒 Files selected for processing (1)
src/renderer/components/chat/viewers/MermaidViewer.tsx
When mermaid 11.13.0 fails to parse a diagram, errorRenderer.draw() paints a large error icon SVG into the temporary
d{id}wrapper that mermaid appends to document.body, then immediately re-throws — skipping the trailing removeTempElements() call (mermaid.core.mjs:1078-1110). The orphan SVG (viewBox 0 0 2412 512) accumulates at the bottom of the page on every failed render.Add a cleanupOrphans() helper in the render effect that removes the
d{id}wrapper div and the{id}SVG node, invoked from a try/finally around mermaid.render() and from the effect cleanup. The component's own error UI is unchanged — failures still surface as a red banner with the original code inside the card.Summary by CodeRabbit