Skip to content

Commit a7a6f02

Browse files
OpenSource03claude
andcommitted
fix: mermaid diagram rendering — sizing, virtualizer overlap, error loops, and text contrast
Five interconnected issues fixed across the mermaid rendering pipeline: 1. **Natural SVG sizing** — Mermaid sets width="100%" + max-width style on SVGs, which combined with CSS [&_svg]:w-full forced every diagram to stretch to container width. Small diagrams (ER) ballooned; wide diagrams (timeline) got cramped. Fix: post-process SVGs to use pixel width from max-width, remove forced-width CSS classes, let overflow-x-auto handle horizontal scroll. 2. **Virtualizer overlap/collapse** — Messages overlapped after mermaid rendered or during scrolling. Two root causes: - Missing getItemKey on virtualizer — sizes were cached by positional index, so when rows shifted (new messages, tool grouping), cached sizes mapped to wrong items. - CHAT_CONTENT_RESIZED_EVENT handler called virtualizer.measure() which nukes the entire itemSizeCache, forcing all items back to bad estimates. The ResizeObserver on each measureElement ref already handles individual size changes — measure() was destructively redundant. 3. **Mermaid error spam loop** — Failed diagrams triggered an infinite unmount/remount cycle: error state changed row height → virtualizer remounted fresh component → retried render → failed again → loop (dozens of errors/sec). Fix: added mermaidErrorCache (module-level Map) so failed renders are cached and never retried on remount. 4. **Container detach crashes** — mermaid.render(id, code, container) passes the component's container for DOM measurements (getBBox, getBoundingClientRect). Virtualizer can unmount the component mid-render, detaching the container and crashing mermaid with null reference errors. Fix: omit the container argument — mermaid creates its own temp element in document.body. 5. **Text contrast on custom fills** — Mermaid's inline style directives (e.g. `style DEV fill:#e1f5ff`) set light pastel fills but don't adjust text color. In dark mode, light theme text on light fills is invisible. Fix: post-process SVG nodes with custom inline fills, compute luminance, and set text to #1a1a1a (light bg) or #f5f5f5 (dark bg). Runs before caching. Also removed the mermaid-specific bubble width override (max-w-[50%]) from MessageBubble — all assistant messages now use consistent max-w-[85%], with diagram sizing handled entirely by MermaidDiagram's own container. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e243248 commit a7a6f02

3 files changed

Lines changed: 118 additions & 22 deletions

File tree

src/components/ChatView.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ function ChatViewContent({
492492
count: rows.length,
493493
getScrollElement: () => scrollContainerRef.current,
494494
estimateSize: (index) => estimateRowHeight(rows[index]),
495+
getItemKey: (index) => getRowKey(rows[index]),
495496
overscan: 5,
496497
});
497498

@@ -582,11 +583,16 @@ function ChatViewContent({
582583
}, [sessionId]);
583584

584585
// ── Content resize (mermaid diagrams etc.) ──
586+
// Don't call virtualizer.measure() here — it nukes the entire itemSizeCache,
587+
// forcing all items back to estimates and causing layout jumps/overlaps.
588+
// The ResizeObserver on each item's measureElement ref already tracks individual
589+
// size changes (e.g. mermaid SVG rendering). We only need to maintain scroll lock.
585590
useEffect(() => {
586591
const handleContentResize = () => {
587-
virtualizer.measure();
588592
if (bottomLockedRef.current && rows.length > 0) {
589-
virtualizer.scrollToIndex(rows.length - 1, { align: "end" });
593+
requestAnimationFrame(() => {
594+
virtualizer.scrollToIndex(rows.length - 1, { align: "end" });
595+
});
590596
}
591597
};
592598
window.addEventListener(CHAT_CONTENT_RESIZED_EVENT, handleContentResize);

src/components/MermaidDiagram.tsx

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { useResolvedThemeClass } from "@/hooks/useResolvedThemeClass";
66
import { CopyButton } from "./CopyButton";
77

88
/** Bump when mermaid config changes to invalidate cached SVGs. */
9-
const MERMAID_RENDER_VERSION = "5";
9+
const MERMAID_RENDER_VERSION = "7";
1010
const MAX_CACHE_ENTRIES = 80;
1111
const mermaidSvgCache = new Map<string, string>();
12+
/** Cache parse errors so failed diagrams don't retry on virtualizer remount. */
13+
const mermaidErrorCache = new Map<string, string>();
1214

1315
// ── Shared theme variables (identical in both light & dark) ─────────────
1416

@@ -210,6 +212,63 @@ function evictCache() {
210212
}
211213
}
212214

215+
/** Parse a CSS color (hex or rgb()) to relative luminance (0–1). */
216+
function getColorLuminance(color: string): number | null {
217+
const hexMatch = color.match(/^#([0-9a-f]{3,8})$/i);
218+
if (hexMatch) {
219+
const hex = hexMatch[1];
220+
if (hex.length === 3) {
221+
const r = parseInt(hex[0] + hex[0], 16);
222+
const g = parseInt(hex[1] + hex[1], 16);
223+
const b = parseInt(hex[2] + hex[2], 16);
224+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
225+
}
226+
if (hex.length >= 6) {
227+
const r = parseInt(hex.slice(0, 2), 16);
228+
const g = parseInt(hex.slice(2, 4), 16);
229+
const b = parseInt(hex.slice(4, 6), 16);
230+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
231+
}
232+
return null;
233+
}
234+
// Browser normalizes inline styles to rgb(r, g, b)
235+
const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
236+
if (rgbMatch) {
237+
const r = parseInt(rgbMatch[1], 10);
238+
const g = parseInt(rgbMatch[2], 10);
239+
const b = parseInt(rgbMatch[3], 10);
240+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
241+
}
242+
return null;
243+
}
244+
245+
/**
246+
* Fix text contrast in nodes with custom inline fills (from mermaid `style` directives).
247+
* Theme-managed fills already pair with correct text colors — only inline overrides break contrast.
248+
*/
249+
function fixNodeTextContrast(container: HTMLElement): void {
250+
const nodes = container.querySelectorAll(".node");
251+
for (const node of nodes) {
252+
const shape = node.querySelector("rect, polygon, circle, ellipse");
253+
if (!shape) continue;
254+
255+
// Only fix nodes with inline fill styles (mermaid `style X fill:...` directives).
256+
const inlineFill = (shape as HTMLElement).style?.fill;
257+
if (!inlineFill) continue;
258+
259+
const lum = getColorLuminance(inlineFill);
260+
if (lum === null) continue;
261+
262+
const textColor = lum > 0.5 ? "#1a1a1a" : "#f5f5f5";
263+
264+
// foreignObject text (flowchart/graph nodes)
265+
const labels = node.querySelectorAll(".nodeLabel");
266+
for (const label of labels) {
267+
(label as HTMLElement).style.color = textColor;
268+
}
269+
}
270+
}
271+
213272
// ── Shared wrapper ──────────────────────────────────────────────────────
214273

215274
function MermaidCard({ label, code, children }: { label: string; code: string; children: ReactNode }) {
@@ -263,10 +322,23 @@ export function MermaidDiagram({ code, isStreaming }: MermaidDiagramProps) {
263322
return;
264323
}
265324

325+
// Check caches — success cache first, then error cache to avoid
326+
// retrying failed renders on virtualizer remount (which causes an
327+
// infinite unmount/remount loop from height changes between states).
266328
const cached = mermaidSvgCache.get(cacheKey);
267329
if (cached) {
268330
container.innerHTML = cached;
269331
setError(null);
332+
requestAnimationFrame(() => {
333+
window.dispatchEvent(new Event(CHAT_CONTENT_RESIZED_EVENT));
334+
});
335+
return;
336+
}
337+
338+
const cachedError = mermaidErrorCache.get(cacheKey);
339+
if (cachedError) {
340+
container.innerHTML = "";
341+
setError(cachedError);
270342
return;
271343
}
272344

@@ -277,18 +349,49 @@ export function MermaidDiagram({ code, isStreaming }: MermaidDiagramProps) {
277349
setError(null);
278350

279351
const id = `mermaid-${requestId}-${Math.random().toString(36).slice(2, 9)}`;
280-
const { svg: renderedSvg } = await mermaid.render(id, code, container!);
352+
// Don't pass our container to mermaid.render() — mermaid uses the
353+
// container for DOM measurements (getBBox, getBoundingClientRect).
354+
// The virtualizer can unmount this component mid-render, detaching
355+
// the container and crashing mermaid. Without a container arg,
356+
// mermaid creates its own temp element in document.body.
357+
const { svg: renderedSvg } = await mermaid.render(id, code);
281358

282359
if (renderRequestRef.current !== requestId) return;
283360

284-
mermaidSvgCache.set(cacheKey, renderedSvg);
285-
evictCache();
286361
container!.innerHTML = renderedSvg;
362+
363+
// Fix SVG dimensions: mermaid sets width="100%" + max-width style,
364+
// which forces all diagrams to stretch to container width. Convert to
365+
// pixel width so diagrams use their natural size (small ones stay small,
366+
// wide ones extend and the container scrolls horizontally).
367+
const svg = container!.querySelector("svg");
368+
if (svg) {
369+
const maxW = svg.style.maxWidth;
370+
if (svg.getAttribute("width") === "100%" && maxW) {
371+
svg.setAttribute("width", maxW);
372+
svg.style.maxWidth = "";
373+
}
374+
}
375+
376+
// Fix text contrast for nodes with custom inline fills (e.g. `style DEV fill:#e1f5ff`).
377+
// Mermaid doesn't adjust text color to match custom fills, so light fills
378+
// get light text in dark mode (invisible). Runs before caching.
379+
fixNodeTextContrast(container!);
380+
381+
mermaidSvgCache.set(cacheKey, container!.innerHTML);
382+
evictCache();
383+
384+
// Notify virtualizer that content height changed after async render
385+
requestAnimationFrame(() => {
386+
window.dispatchEvent(new Event(CHAT_CONTENT_RESIZED_EVENT));
387+
});
287388
} catch (err) {
288389
if (renderRequestRef.current !== requestId) return;
289390

290391
container!.innerHTML = "";
291-
setError(reportError("MERMAID_RENDER", err));
392+
const errorMsg = reportError("MERMAID_RENDER", err);
393+
mermaidErrorCache.set(cacheKey, errorMsg);
394+
setError(errorMsg);
292395
}
293396
}
294397

@@ -301,17 +404,6 @@ export function MermaidDiagram({ code, isStreaming }: MermaidDiagramProps) {
301404
};
302405
}, [cacheKey, isStreaming]);
303406

304-
// Notify ChatView that async content changed size (for scroll settling)
305-
useEffect(() => {
306-
if (typeof window === "undefined" || isStreaming || !containerRef.current?.innerHTML) return;
307-
308-
const frame = window.requestAnimationFrame(() => {
309-
window.dispatchEvent(new Event(CHAT_CONTENT_RESIZED_EVENT));
310-
});
311-
312-
return () => window.cancelAnimationFrame(frame);
313-
}, [cacheKey, isStreaming]);
314-
315407
if (error) {
316408
return (
317409
<MermaidCard label="mermaid (error)" code={code}>
@@ -339,7 +431,7 @@ export function MermaidDiagram({ code, isStreaming }: MermaidDiagramProps) {
339431
<MermaidCard label="mermaid" code={code}>
340432
<div
341433
ref={containerRef}
342-
className="flex min-h-24 items-center justify-center overflow-x-auto p-4 [&_svg]:h-auto [&_svg]:max-w-full [&_svg]:min-w-full [&_svg]:w-full"
434+
className="flex min-h-24 items-center justify-center overflow-x-auto p-4 [&_svg]:h-auto"
343435
/>
344436
</MermaidCard>
345437
);

src/components/MessageBubble.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,6 @@ export const MessageBubble = memo(function MessageBubble({
325325
// reliably — e.g. after session switch-back, persistence restore, or within
326326
// Radix ScrollArea. Always rendering markdown is fast enough for individual
327327
// messages; for truly long chats, proper virtualization should be used instead.
328-
const hasMermaidContent = containsMermaidFence(message.content);
329-
330328
const hasRenderableAssistantContent = !!message.content || (showThinking && !!message.thinking);
331329
if (!hasRenderableAssistantContent) {
332330
return null;
@@ -336,7 +334,7 @@ export const MessageBubble = memo(function MessageBubble({
336334
<div className={`flex justify-start px-4 ${isContinuation ? "py-0.5" : "py-1.5"}`}>
337335
<Tooltip>
338336
<TooltipTrigger asChild>
339-
<div className={cn("min-w-0 wrap-break-word", hasMermaidContent ? "w-full max-w-[50%]" : "max-w-[85%]")}>
337+
<div className="min-w-0 wrap-break-word max-w-[85%]">
340338
{showThinking && message.thinking && (
341339
<div className={message.content ? "mb-2" : undefined}>
342340
<ThinkingBlock

0 commit comments

Comments
 (0)