Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions frontend/app/element/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,36 @@ import { openLink } from "../store/global";
import { IconButton } from "./iconbutton";
import "./markdown.scss";

let mermaidInitialized = false;
let mermaidInstance: any = null;

const initializeMermaid = async () => {
if (!mermaidInitialized) {
const mermaid = await import("mermaid");
mermaidInstance = mermaid.default;
mermaidInstance.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
mermaidInitialized = true;
}
let mermaidInitPromise: Promise<any> = null;
let mermaidRenderQueue: Promise<void> = Promise.resolve();

const initializeMermaid = async (): Promise<any> => {
// Memoize the promise rather than setting a flag after the await. A page with several
// diagrams mounts every Mermaid component at once, so all of them would pass a flag guard
// before the first import resolved and each would call initialize() again, resetting
// mermaid's global config while other renders were in flight.
mermaidInitPromise ??= import("mermaid").then((mermaid) => {
mermaid.default.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
return mermaid.default;
});
return mermaidInitPromise;
};

// mermaid.run() mutates module-global state and derives its SVG element id from Date.now(), so
// two calls that overlap (or land in the same millisecond) can produce colliding ids and render
// into each other. Queue the calls so only one runs at a time.
const runMermaid = (mermaidInstance: any, node: HTMLElement, chartText: string): Promise<void> => {
const result = mermaidRenderQueue.then(() => {
// Fill the node inside the queued task, not before queueing. mermaid reads the node's
// contents when the task runs, so if a later effect wrote to the same node while this
// task was waiting its turn, the render would pick up the wrong chart.
node.removeAttribute("data-processed");
node.textContent = chartText;
return mermaidInstance.run({ nodes: [node] });
});
// Keep the queue alive when a diagram fails to parse; the caller still sees the rejection.
mermaidRenderQueue = result.catch(() => {});
return result;
};

const Link = ({
Expand Down Expand Up @@ -79,7 +99,7 @@ const Mermaid = ({ chart }: { chart: string }) => {
setIsLoading(true);
setError(null);

await initializeMermaid();
const mermaidInstance = await initializeMermaid();
if (!ref.current || !mermaidInstance) {
return;
}
Expand All @@ -90,10 +110,8 @@ const Mermaid = ({ chart }: { chart: string }) => {
.replace(/\r\n?/g, "\n") // Normalize \r \r\n to \n
.replace(/\n+$/, ""); // Remove final newline

ref.current.removeAttribute("data-processed");
ref.current.textContent = normalizedChart;
// console.log("mermaid", normalizedChart);
await mermaidInstance.run({ nodes: [ref.current] });
await runMermaid(mermaidInstance, ref.current, normalizedChart);
setIsLoading(false);
} catch (err) {
console.error("Error rendering mermaid diagram:", err);
Expand Down