From a56798c2b09f15702eb2a1a7a9b9f66acd6fda32 Mon Sep 17 00:00:00 2001 From: Aniket Das Date: Tue, 18 Aug 2026 18:23:36 -0400 Subject: [PATCH 1/2] fix: serialize concurrent mermaid renders in markdown preview Every Mermaid component calls mermaid.run() from its own useEffect, so a markdown file with several diagrams starts all of the renders in parallel. mermaid.run() mutates module-global state and derives its SVG element id from Date.now(), so calls that overlap or land in the same millisecond can produce colliding ids and render into one another. Queue the run() calls so only one executes at a time, and memoize the mermaid init promise instead of setting a flag after the await, which let every component get past the guard and call initialize() again while other renders were still in flight. Fixes #3191 --- frontend/app/element/markdown.tsx | 37 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 5ecc252876..111d509643 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -26,16 +26,29 @@ 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): Promise => { + const result = mermaidRenderQueue.then(() => 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 +92,7 @@ const Mermaid = ({ chart }: { chart: string }) => { setIsLoading(true); setError(null); - await initializeMermaid(); + const mermaidInstance = await initializeMermaid(); if (!ref.current || !mermaidInstance) { return; } @@ -93,7 +106,7 @@ const Mermaid = ({ chart }: { chart: string }) => { ref.current.removeAttribute("data-processed"); ref.current.textContent = normalizedChart; // console.log("mermaid", normalizedChart); - await mermaidInstance.run({ nodes: [ref.current] }); + await runMermaid(mermaidInstance, ref.current); setIsLoading(false); } catch (err) { console.error("Error rendering mermaid diagram:", err); From c3d805c4d8b2b08a0b2a2b34f088daf610e2ca14 Mon Sep 17 00:00:00 2001 From: Aniket Das Date: Tue, 18 Aug 2026 18:59:58 -0400 Subject: [PATCH 2/2] fix: fill the diagram node inside the queued render task The node's contents are what mermaid.run() reads when the queued task executes. Writing them before queueing left a window where a later effect on the same node could replace the text an earlier queued render was about to read, so a render could pick up the wrong chart. --- frontend/app/element/markdown.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 111d509643..18f99e49a6 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -44,8 +44,15 @@ const initializeMermaid = async (): Promise => { // 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): Promise => { - const result = mermaidRenderQueue.then(() => mermaidInstance.run({ nodes: [node] })); +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; @@ -103,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 runMermaid(mermaidInstance, ref.current); + await runMermaid(mermaidInstance, ref.current, normalizedChart); setIsLoading(false); } catch (err) { console.error("Error rendering mermaid diagram:", err);