Last updated on

It rendered in the playground but not on my site: a Mermaid, ELK, and React story


Part 1 of 2 in the series Rendering Mermaid Diagrams in PayloadCMS Posts. Full series ↓

How the diagrams render

My blog runs on Payload CMS with a Lexical rich-text editor. Mermaid isn't a Markdown code fence here; it's a custom block that renders client-side. The component lazy-imports mermaid, validates the source, and injects the resulting SVG:

const mermaid = (await import("mermaid")).default;
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "default" });

if (await mermaid.parse(diagram, { suppressErrors: true })) {
  const { svg } = await mermaid.render(id, diagram);
  // inject svg
}

Nothing exotic. This is the pattern the whole community converges on in the long-running "React examples" issue: render in an effect, inject the SVG string. Keep that in mind, it matters later.

Clue 1: the layout that silently downgraded

My diagram — the parallel-migration fan-out from when two migrations collide — started with a directive I copied from the playground:

---
config:
  layout: elk
---

ELK is a nicer layout engine. It routes edges as clean right angles and groups nodes more sensibly than the default (dagre). In the playground it did exactly that. On my site the same diagram came out cramped and stacked.

The reason is a change in Mermaid 11: ELK is no longer built in. It ships as a separate package you have to register yourself. If it isn't registered, the layout: elk directive is simply ignored and you fall back to dagre. No warning, no error. So I installed it and wired it up:

const elkLayouts = (await import("@mermaid-js/layout-elk")).default;
mermaid.registerLayoutLoaders(elkLayouts);

That fixed the layout. It also introduced a much better bug.

Clue 2: "Converting circular structure to JSON"

With ELK registered, any diagram that contained a subgraph rendered as a plain block of source text instead of a picture. My component falls back to showing the raw source when rendering throws, and something was throwing. The catch block had swallowed it silently, so the first job was to get the real error out. Once I did:

TypeError: Converting circular structure to JSON
    starting at object 'HTMLHtmlElement'
    property '__reactFiber$…' -> FiberNode -> stateNode closes the circle
    at JSON.stringify
    at Module.render        (@mermaid-js/layout-elk)

Two things jumped out. It was happening inside ELK's renderer, and the circular reference ran through a React fiber attached to the page's <html> element.

The root cause

ELK's renderer deep-clones each subgraph node like this:

const clusterNode = JSON.parse(JSON.stringify(node));

node is not a DOM element. It's a plain layout object with an id, coordinates, and a label. But one of its properties, node.domId, is a d3 selection. And a d3 selection carries a _parents array that, for a top-level selection, points at document.documentElement, which is to say <html>.

So the walk goes: nodenode.domId_parents[0]<html>. On most pages, serializing an <html> element produces {}, because DOM elements have no own enumerable properties. On a React-hydrated page they do: React sets __reactFiber$… as a real enumerable property directly on the DOM node. JSON.stringify follows it into the fiber tree, the fiber points back at its host element, and you have a cycle.

That single detail explains the whole mystery. The Mermaid playground isn't a React app, so <html> has no fibers and the same clone serializes cleanly. My site is a React app, so it doesn't. This is a known, still-open bug (mermaid#5530), and the reporter there noted they couldn't reproduce it in isolation. Of course not. You need a framework hydrating the document to plant the fibers.

The fork in the road

There were three ways forward, and none of them were free:

  1. Render inside a sandboxed iframe (securityLevel: "sandbox"). The iframe has its own document with no React, so no fibers. It works, but the diagram is now an iframe instead of inline SVG, with a fixed height and no shared styling.
  2. Patch the dependency so the clone skips DOM references. Keeps inline SVG, but now I'm maintaining a patch against minified code.
  3. Render on the server to static SVG. The correct long-term answer, and no React at render time means no bug, but it needs a headless browser in the build. That cost is exactly why my server-side path was still an open TODO.

I built the sandbox version first and it rendered perfectly. Then I stopped and asked whether I actually needed any of this.

The reframe

Go back to that community issue about rendering Mermaid in React. Every solution in it uses the default dagre engine. None of them hit this crash, because dagre never runs the JSON-clone that ELK does. The idiomatic client-side approach is inline SVG plus dagre, and it just works.

In other words, ELK was the only thing dragging hacks into my code. The real question wasn't "how do I render Mermaid in React," which is a solved problem. It was "is ELK's prettier routing worth an iframe or a patch for this one diagram?"

It wasn't. And here's the kicker: the playground's clean layout wasn't even ELK-exclusive. My diagram used an invisible subgraph plus direction LR to force two nodes side by side, a trick ELK needs but dagre doesn't. For a simple fan-out, dagre already puts sibling nodes on the same row. I deleted the direction LR line, dropped ELK entirely, and the dagre render came out essentially identical to the playground:

None of it needed an extra dependency, an iframe, or a patch: just inline SVG running under strict, exactly like the playground.

What I took away

A few things I want to remember:

JSON.parse(JSON.stringify(x)) is not a deep clone. It's a serialization that quietly assumes every value is JSON-safe. Hand it a DOM node, a function, or a cycle and it either drops data or throws. It happened to work in ELK for years because nobody serialized it next to a framework-managed DOM.

Framework-managed DOM is hostile to naive serialization. React's fibers are enumerable own-properties sitting right on the DOM nodes. Anything that walks an element with JSON.stringify will wander into React's internals. This is a general hazard, not a Mermaid one.

"Works in the playground" means "works in a different environment." The playground had no React, which is the entire difference. Reproduce in the environment you actually ship.

The fanciest piece was the problem. I added a dependency to make a diagram prettier, and it was the dependency causing the failure. Removing it, and spending five minutes tuning the diagram for the tool I already had, was the better fix. I left a note for my future self documenting the constraint so I don't re-learn it next time.

If I ever do want ELK's square-elbow routing, there's a clean place for it: the server-rendered path, where there's no React in the tree and therefore no fiber to trip over. Until then, dagre is doing just fine.

Comments

No comments yet. Be the first to comment.

Leave a comment