Skip to content

fix(web): cache mermaid svgs and retry a failed library load

MacroscopeApp / Macroscope - UI Consistency failed Aug 29, 2026 in 7m 39s

UI Consistency: 1 issue found

1 finding

  • apps/web/src/components/chat/MermaidDiagram.tsx — the render effect clears the current SVG on every dependency change, and the render cache is keyed by theme, so an app theme switch collapses each visible diagram to the one-line Rendering diagram… placeholder and then snaps it back to full height. The sibling code-block path (UncachedShikiCodeBlock) re-themes synchronously and keeps its height, so the same chrome behaves two different ways. Suggested fix: keep the last rendered SVG while a re-render is in flight and reset on content change via key={code} at the ChatMarkdown.tsx call site.

Previously reported issues that are now resolved: render caching for remounts, suppressErrorRendering to stop leaked temp error diagrams, replacing the max-width: none !important override with per-diagram-type useMaxWidth config, mermaid-aware text/html clipboard flavor for both diagram-only and mixed selections, non-<p> placeholder/error elements, and the shared chat-markdown scrollbar treatment on the new horizontal scroll surface.

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.

Files examined in scope: apps/web/src/components/chat/MermaidDiagram.tsx, apps/web/src/components/ChatMarkdown.tsx, apps/web/src/lib/mermaidRenderer.ts, apps/web/src/lib/mermaidLanguage.ts, apps/web/src/markdown-clipboard.ts, apps/web/src/proposedPlan.ts, apps/web/src/index.css (plus their tests as context).

Checks performed:

  • Shared controls: the new source/diagram toggle reuses Button variant="ghost" size="icon-xs" with chat-markdown-chrome-action and a Tooltip, matching the existing wrap/copy actions, with aria-pressed and an aria-label that tracks the label. No raw reconstruction of a primitive.
  • CSS ownership: the new .chat-markdown .chat-markdown-mermaid rules are grouped with the existing .chat-markdown pre scrollbar rules and style imperatively injected SVG, which is an appropriate global-CSS case; component-local geometry (padding, overflow) stays as Tailwind on the owner.
  • Clipboard: verified the live-ancestor mermaid branch, the data-markdown-copy early return in serializeNode, the partially-cloned-wrapper path, and the sanitizedHtmlFrom fence substitution; the text/html flavor now preserves the fence for diagram-only and mixed prose+diagram selections.
  • Plan preview: nextOpenMarkdownFence overshoot is bounded by the collapsed card's max-h-104 overflow-hidden clip in ProposedPlanCard, matching the inline comment, and unclosed fences are dropped back to the fence start.
  • Theming: resolvedTheme is "light" | "dark", so the renderer's two-theme cache key is sound; custom theme palettes are not bridged into mermaid, but that is a product-level gap rather than a regression introduced here.

🔍 Agent activity — 17 tool calls

💭 I'm checking the current state of the PR at head 1c53982 to see if the issues I flagged earlier were addressed and whether any new problems have popped up, so I need to look at the current files.

1. browse_code — apps/web/src/components/chat/MermaidDiagram.tsx

2. browse_code — apps/web/src/lib/mermaidRenderer.ts

💭 The prior issues seem resolved - caching, error suppression, max-width config, clipboard handling, and placeholder markup all look addressed. Now I'm checking index.css, markdown-clipboard.ts, and ChatMarkdown.tsx for any remaining gaps, and I notice the error and placeholder branches in MermaidDiagram may be missing the consistent class naming used elsewhere.

3. browse_code — apps/web/src/markdown-clipboard.ts

4. browse_code — apps/web/src/index.css

💭 Checking the remaining CSS rules — the mermaid scrollbar and SVG styling both target injected/imperative content, so keeping them in global CSS alongside the existing pre rules seems fine. But I'm noticing an inconsistency: only certain diagram types get useMaxWidth: false, while others like journey, timeline, and quadrantChart default to useMaxWidth: true, meaning mermaid emits width="100%" and scales to fit the container instead of rendering at natural size with scroll. So diagrams of unlisted types behave differently from listed ones — some scroll horizontally, others scale responsively — which is a real consistency gap within the same feature, though I'm not sure yet whether it counts as a genuine violation versus just an incomplete config. I'm also checking whether mermaid config keys like class, er, gitGraph, and mindmap are valid — they should be — and looking at whether the clipboard-copy logic properly falls back to markdown when the diagram is in an error or loading state, since that path relies on specific data attributes and selectors to find the live node. Then I'm moving on to the ChatMarkdown.tsx changes for the code block chrome — checking how the wrap toggle is hidden when a diagram is shown, how the source toggle works, and whether the copy button correctly copies the underlying code source versus the diagram itself, plus verifying the necessary hooks are imported.

5. browse_code — apps/web/src/components/ChatMarkdown.tsx

💭 I'm checking whether duplicate error messages could appear—tracing through the logic to confirm that when a diagram fails, only the parent's error div renders since MermaidDiagram unmounts before it can show its own error branch. I've also ruled out other race conditions: the onError callback is stable via useCallback, the cached-svg initializer isn't affected by theme switches, and concurrent renders with different themes aren't actually possible since only one theme applies at a time. The mermaid.initialize call also seems safe even if multiple renders await loadMermaid() concurrently, since they'd all initialize with the same theme. I'm now going through a checklist of smaller UI details—theme toggling per diagram, accessibility attributes on the source toggle, the wrap toggle being hidden appropriately, and the copy button copying the correct source code. I'm also verifying that the data-mermaid attribute is just a harmless test hook, that the mermaid scrollbar styles are properly scoped within .chat-markdown, and that the SVG width behaves correctly with useMaxWidth set to false. I'm now checking how the selection/copy selector interacts with the sanitized HTML. Then I trace how skipped elements (like svg, buttons, aria-hidden icons) get handled in copy serialization—specifically that skipped elements fall back to their closest data-markdown-copy ancestor, and I'm worried this could cause a regression where a skipped decorative element nested inside a data-markdown-copy container ends up emitting the full copy text unexpectedly, since I need to check what other elements carry that attribute besides anchors. When a selection spans from inside a diagram into surrounding prose, the ancestor is above the mermaid wrapper, so it takes the clone path instead — sanitizedHtmlFrom swaps the mermaid host's innerHTML for the fence text, but the clone still carries a partial mermaid wrapper retaining the data-markdown-copy attribute, which could cause serialization to grab that copy prematurely. I notice they split scrollbar styling into its own grouped rule rather than keeping it inline — reasonable refactor. Checking the loading/error UI, it uses plain divs with Tailwind rather than shared components, and there's a minor inconsistency where padding differs slightly between the diagram error rendered in ChatMarkdown versus MermaidDiagram. I'm also verifying accessibility attributes look fine, and confirming the caching approach avoids remount flicker since cached SVGs return synchronously on mount — that matches what I flagged earlier. The mermaid render config uses strict security with DOMPurify sanitization, so injecting the SVG via dangerouslySetInnerHTML seems acceptable given that setting. I'm now spotting a potential ID collision problem: since mermaid renders require appending temp elements to the DOM and generate unique render IDs, if the same diagram gets rendered twice via the cache, both instances would produce identical internal SVG element IDs (like markers and clip paths). Since SVG references resolve to the first matching ID in document order, duplicate diagrams on the page could visually reference the wrong elements, though since the SVGs are identical, the visuals might still render correctly by coincidence. Checking the scoped style injection, mermaid embeds a <style> block inside each SVG keyed to its own id — with duplicate ids this would still work fine since both blocks target valid elements. I want to verify the overflow-x-auto scroll container is properly styled and doesn't conflict with the virtualized-scroll-fade contract, which seems fine since code blocks don't use fades. Now I'm checking the actual call site for ChatMarkdown around line 2259.

6. browse_code — apps/web/src/components/ChatMarkdown.tsx

💭 I'm checking whether resolvedTheme covers only light/dark or if the app supports custom themes, since the instructions want custom themes and runtime token bridges preserved. The mermaid theme mapping only distinguishes dark from everything else as "neutral," so custom theme colors won't fully carry through to diagrams—I'm treating that as an acceptable tradeoff for now. I also notice the "Rendering diagram…" placeholder lacks a min-height, so there could be a layout shift once the SVG renders in. Beyond that, theme switching is a real concern: toggling theme clears the cached svg for all visible diagrams, collapsing them back to the one-line placeholder before re-expanding, which combined with maintainVisibleContentPosition causes a jarring content jump. A cleaner fix would be to only clear the svg when the diagram code itself changes, keeping the stale svg visible during a theme-only re-render instead of nulling it out every time. I'm now checking whether copy-to-clipboard still works when a diagram fails to render and falls back to showing its source, and also looking at how the app resolves its theme to see if it supports custom palettes beyond light/dark.

7. git_tools — resolvedTheme

8. git_tools — chat-markdown-mermaid|data-mermaid

💭 I need to check the type of resolvedTheme from useTheme() since it might only support light/dark, not custom themes. I also want to verify whether chat-markdown-mermaid is always nested under a chat-markdown parent by checking where ChatMarkdown places that class, and whether any other surface like a plan preview renders this too.

9. git_tools — chat-markdown\b

10. browse_code — What does useTheme() return for resolvedTheme in apps/web (type and possible values)? Does the app support custom themes beyond light/dark, and how is resolvedTheme derived (e.g. resolveDiffThemeName)?

💭 Custom themes only affect CSS variables, but mermaid diagrams use their own built-in dark/neutral palettes independent of theme colors. Since mermaid's cache keys off appearance mode only, switching between custom themes sharing the same appearance produces identical cached SVGs—which is expected behavior, not a bug, since mermaid never derived its palette from the custom theme anyway. This just means there's no custom-theme integration for mermaid diagrams, not a staleness issue. I'm now considering whether this counts as a UI-consistency finding under the rule that contrast/accessibility settings targeting app chrome must derive from semantic tokens. Diagrams are generated content though, and I'm checking how mermaid's neutral vs dark theme renders node fills and text against the surrounding code block background in each app theme—seems reasonably consistent either way. I'm also wondering whether the contrast accessibility setting (with its own foreground/border variables) interacts with this at all. Setting height: auto on the svg alongside mermaid's own width/height attributes should still preserve aspect ratio correctly since the viewBox handles scaling, so that seems fine. A more concrete candidate issue might be around how svg elements get skipped in element-processing logic. I trace file-link chip anchors: since anchors check data-markdown-copy before anything else, any anchor with that attribute returns early and its children (icons, svgs) are never separately visited, which is correct behavior. If a selection instead starts inside the chip itself, capturing just the inner spans/svg without the anchor wrapper, the detached clone would look up closest("[data-markdown-copy]") and find nothing, matching prior behavior. So the real regression risk must be elsewhere, in the live-DOM serialization path rather than the clone-based one. Now I'm considering mermaid diagram wrappers, which also carry data-markdown-copy. When serializing the wrapper itself, that attribute check returns the copy immediately, so its inner svg is never independently serialized — no duplication there. But a trickier case is a selection that begins partway inside the svg and extends past the wrapper into surrounding prose; cloneContents would then produce a partial wrapper div (still carrying the attributes) containing a partial svg, and I need to trace how the attribute check handles that partial clone. For the html flavor, sanitizedHtmlFrom swaps the mermaid host's innerHTML for the fenced pre/code block, which works fine. For plain text, a selection entirely inside a diagram returns the whole fence — even if the user only selected a single node label, which seems like acceptable behavior for this design. Now I'm turning attention to chatMarkdownClipboardPayload and whether it introduces similar edge cases. Looking at the toolbar header, it conditionally shows 2-3 buttons — the wrap toggle disappears while a diagram is displayed and reappears when switching to source view, causing a minor layout shift that seems acceptable. I'm also checking aria-pressed={showSource} on the toggle button, which looks fine since only the label changes. The bigger remaining candidates to examine are the theme-switch behavior that clears the SVG, and confirming there's no missing data-markdown-copy handling for MermaidDiagram, which appears already covered. I'm also noting that the rendering placeholder and error states use plain text/div elements rather than matching the rest of the code block's chrome — seems fine. Then I check how the cache stores the rendered SVG, verifying the LRUCache set call and its expected signature.

11. browse_code — apps/web/src/lib/lruCache.ts

💭 I'm considering whether concurrent renders of identical diagrams could race—since mermaid.render relies on shared global state, simultaneous calls from cache misses might interleave unsafely rather than queue properly. I'm reasoning that this is speculative since mermaid likely handles per-diagram config internally, so it's not worth flagging. Also confirming that shiki highlighting isn't wasted for mermaid blocks that render as diagrams, since the children subtree just isn't rendered in that case. Now I want to look at the proposedPlan.ts changes in scope, specifically checking the fence tracking logic for correctness issues.

12. browse_code — apps/web/src/proposedPlan.ts

💭 I'm noting a minor edge case where fences inside blockquotes could mismatch closing markers since the prefix stripping doesn't distinguish nesting levels. The unclosed fence handling and index tracking both look correct, but I should also check how fences behave when indented inside list items by four or more spaces. Another edge case worth checking is whether an unclosed fence could unbounded-consume lines, meaning a huge code block like a 500-line mermaid diagram would render fully inside what's supposed to be a collapsed preview card — I need to verify whether that card actually clips content via CSS max-height, or whether the fence would just expand it.

13. browse_code — Where is buildCollapsedProposedPlanPreviewMarkdown used in apps/web? Show the component rendering the collapsed plan preview and whether it CSS-clips the preview height (max-height / line-clamp / overflow hidden).

💭 The comment checks out and the preview overshoot is handled by CSS clipping. The mermaid diagram still renders properly inside the collapsed, clipped box, and streaming state doesn't seem to affect it there. Let me look once more for a concrete UI-consistency issue. I'm noticing minor padding inconsistencies between the loading, diagram, and error states in the mermaid component, but nothing significant enough to flag. The test-only data attribute isn't a real violation since it's not about deleted code. And the scrollable diagram container not being keyboard-focusable by default seems consistent with how pre elements already behave across browsers, so that's not a new issue either. The sanitizer already strips style tags from the SVG mermaid produces, so that's fine, and while the injected SVG lacks a proper accessible role, mermaid does add its own roledescription so it's a weak finding at best. The real issue I keep coming back to is the layout collapse on theme toggle: when the resolved theme flips, the diagram cache misses for the new theme, forcing the component through a placeholder state that shrinks the diagram's height dramatically before it re-expands once rendering finishes, which could cause visible jank especially with multiple diagrams in a conversation. Since the component already fixed this same issue for remounts using a cache, I'm thinking the cleanest fix would be to avoid clearing the SVG on theme changes specifically -- keep rendering the stale SVG (maybe dimmed) until the new one is ready, only clearing state when the actual diagram code changes. I need to check if a failed re-render after keeping the stale SVG causes any dangling state issues, but that seems manageable. I'm also checking whether the shiki code blocks handle theme-change re-highlighting the same way, looking at how they cache highlighted HTML and use Suspense to avoid a flash -- if they keep showing the previous HTML while rehighlighting, that's the pattern this component should mirror.

14. browse_code — apps/web/src/components/ChatMarkdown.tsx

💭 Since shiki's highlighter loads once and then re-highlights synchronously in useMemo, code blocks never flash a placeholder on theme toggle. Mermaid diagrams are different though—their cache is keyed by theme, so the effect nulls out the svg and the diagram collapses to a one-line placeholder every time the theme changes, which is inconsistent behavior between the two. This visibly causes every diagram in the transcript to jump on theme toggle, disrupting the virtualized scroll position. The fix is straightforward: avoid clearing the svg when only the theme changed, and instead keep showing the last rendered diagram until the new one is ready, only resetting when the actual code content changes (which is rare since streaming disables re-rendering). I'm weighing implementation options — using a ref to track the previously rendered code would require adding a new import, so I'm considering a lighter approach: storing the rendered svg alongside the code it was generated from in state, and deriving the displayed svg by comparing against the current code, avoiding the need for refs or extra imports. Since the key change touches another file, this falls under the "no diff" case since it's cross-file, so I'll just describe the fix instead. I'm also considering a self-contained version entirely within MermaidDiagram, tracking rendered state keyed by code identity so the svg only shows when it matches the current code. I realize this approach still handles theme changes correctly since the code identity stays the same, so the stale svg persists until the new theme renders, while a code change nulls the svg immediately as a placeholder -- exactly the behavior I want, though it spans multiple hunks in one file. I'll describe the fix in prose along with a small diff for the effect hunk, and note that the parent needs a key={code} on the ChatMarkdown call to reset properly on code changes. I want to double-check that toggling the theme keeps the diagram component mounted rather than remounting it -- since the tree structure stays the same and only the theme prop changes, the effect should just re-run rather than the component unmounting. That confirms mermaid's dynamic import is only a cost on first load, but re-rendering diagrams on theme toggle still takes noticeable time per diagram, causing a brief visible flash that's real but short-lived, and I'm weighing whether that's significant enough to flag as a failing issue. I'm also scanning the test file and component logic for edge cases -- confirming the streaming test doesn't expect a mermaid attribute, checking that resetting state on code changes is harmless on mount, and noting that toggling back to the diagram view after viewing source will retrigger the render and likely hit the same error again. I'm reasoning through the UX for a permanently broken diagram: clicking "Show diagram" would just flash the error and bounce back to source view, which seems like acceptable feedback rather than a real bug. I'm also confirming that when there's a diagram error, both the error message and source are shown together with the toggle button correctly reflecting pressed state, and that the wrap-toggle attribute lingering on the container while the diagram is visible is irrelevant. Now I'm checking where the new mermaid-related CSS rules were placed in index.css, cross-referencing against the styling ownership convention that says single-owner presentational styles should live as Tailwind classes in the owning component file rather than global CSS. Since these styles apply to generated markdown output and injected SVG content -- and share behavior with an existing global pre rule for things like scrollbar and vendor pseudo-elements -- keeping them in global CSS seems justified rather than a violation. I'm digging into whether setting height: auto on the injected mermaid SVG could conflict with explicit height attributes mermaid sets when useMaxWidth is false, so I'm tracing through mermaid's internal SVG sizing logic to see how it assigns width/height styles. Since CSS overrides the SVG's geometry attributes, setting height:auto alongside a fixed width means the actual rendered height would need to come from the SVG's aspect ratio via its viewBox, so I'm working out whether that resolves correctly given how mermaid typically sets up its viewBox. Aspect ratio scaling should be fine there. I'm also checking that strict security level in mermaid properly sanitizes rendered diagrams via DOMPurify, which matters since this content is injected through dangerouslySetInnerHTML in the chat—so that sanitization step is a real safety consideration, not just a UI nicety. Now I'm looking at whether mermaid.render needs the element attached to the DOM before measuring it. I'm deciding to post one finding about the theme-toggle collapse issue, and thinking through whether it's distinct enough from the prior accepted fix. The earlier fix addressed remount replay by adding caching for rendered diagrams, but the theme-toggle case is a different trigger—a deliberate cache miss on theme change—so it's a related but separate issue worth flagging on its own. I'm weighing whether the visual flash from the collapsing/expanding diagram is severe enough to fail the check, since maintainVisibleContentPosition compensates for scroll position but the diagram itself still visibly jumps during the theme repaint. It's a marginal case, so I want to do one more pass to see if there's a clearer, more concrete violation before deciding whether to report this or mark things as clear. I'm now checking the clipboard copy logic against actual DOM behavior, since that's historically where bugs have surfaced. Looking at the new mermaid copy branch, I'm tracing what happens if a selection falls entirely within the loading placeholder or error state rather than the rendered diagram element, and separately checking whether a selection spanning the diagram plus its select-none header behaves correctly, before moving on to look at the existing pre-block handling logic. Now I'm working through the sanitized HTML path—since the container is always a fresh wrapper around the cloned selection, the mermaid host element will appear as a descendant, so a partial selection that begins inside the SVG diagram and ends in later prose would still trigger the clone-based path since the common ancestor sits outside the diagram, meaning the cloned mermaid div gets serialized through the fragment logic. Checking one more edge case: whether the mermaid fence output handles the language annotation correctly and whether selections including both the diagram and its copy button text get properly trimmed after tidying the markdown. I'm also verifying that skipped elements like buttons inside markdown details correctly fall back to their nearest ancestor with the copy-relevant data attribute. Now I'm tracing whether file-link chips nested inside table cells correctly early-return during serialization since their anchor tags carry the copy attribute, confirming the SVG icons inside get handled separately by the sanitizer. I'm hunting for a scenario where double-emission could happen — specifically when an element inside a copy-marked ancestor gets serialized without that ancestor being visited first, which would only occur if the clone's root excluded the ancestor from the walk. I want to check if there's a live-DOM caller of the fragment serializer, like a copy-as-markdown feature that operates directly on the live message root rather than a clone, since that's the one place duplication could sneak in — but tracing through the mermaid host case shows the copy attribute check still fires correctly and nothing gets double-visited. With the clipboard behavior confirmed safe, I'm settling on reporting the theme-toggle finding, and double-checking that the new toggle button follows the same styling conventions as its siblings, which it does. Now I'm comparing the icon choice for the source/diagram toggle against similar patterns elsewhere in the codebase to see if there's an established convention I should match.

15. git_tools — EyeIcon|Code2Icon|Show source|Show preview

💭 I'm checking whether other icons in this same header use aria-hidden for consistency, and it looks like they don't, so this matches convention. I also want to verify where these icons are imported from, likely lucide-react, to confirm it aligns with the rest of the repo.

16. browse_code — apps/web/src/components/ChatMarkdown.tsx

💭 I've decided to focus my finding on the theme-change collapse issue in MermaidDiagram, proposing removal of the setSvg(null) reset. But I need to check whether this alone is sufficient, since stale SVG could linger on code changes too—though since code only stabilizes after streaming ends, this may not actually be a problem in practice. The safer fix would be adding key={code} at the call site to force a remount on code changes while dropping the reset on theme swaps, so I'll describe this cross-file approach rather than writing a diff. I'm weighing whether a more contained single-file fix using a derived-state pattern is worth the added complexity versus just describing the two-part change in prose. I'll drop the diff idea and just describe the issue in prose instead, pointing to the setSvg(null) line in MermaidDiagram.tsx as the concrete spot. I'll frame the comment around how switching themes wipes every rendered diagram back to the "Rendering diagram…" placeholder, which breaks visual consistency with how the sibling shiki path handles re-theming without a placeholder flash. The fix I'll suggest: don't clear the svg immediately on re-render, keep showing the last rendered version until the new one is ready, and instead key the component on the code content at its usage site so it resets only when content actually changes. Then I'll post this as a comment via the github_pr_review tool.

17. modify_pr — Reviewed the mermaid chat-diagram changes for UI consistency. The earlier findings (render cache, suppressErrorRendering, useMaxWidth handling instead of a max-width: none !important override, mermaid-aware clipboard HTML, non-<p> placeholders, shared code-block scrollbar treatment) are addressed. One residual layout-stability issue remains on theme switching.


Agent Credits: 296 credits

Token usage: 56 uncached input · 1,580,235 cache-read input · 204,339 cache-write input · 29,851 output
Agent Credits may also include non-token charges from external tools such as web research.