diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 5ecc252876..18f99e49a6 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -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 = null; +let mermaidRenderQueue: Promise = Promise.resolve(); + +const initializeMermaid = async (): Promise => { + // 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 => { + 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 = ({ @@ -79,7 +99,7 @@ const Mermaid = ({ chart }: { chart: string }) => { setIsLoading(true); setError(null); - await initializeMermaid(); + const mermaidInstance = await initializeMermaid(); if (!ref.current || !mermaidInstance) { return; } @@ -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);