From 532fd1258d9f49a699f1a499f08967fb11b04fe0 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Mon, 19 Oct 2026 03:41:19 +0000
Subject: [PATCH 1/3] feat(web): render Mermaid diagrams with pele
Pele draws Mermaid source to SVG synchronously and lazy-loads one chunk per
diagram type. Diagrams read their colors from CSS variables mapped to the app
theme, so theme changes recolor them without re-rendering, and narrow
containers get a reflowed layout instead of a scaled-down one.
---
apps/web/package.json | 2 +-
apps/web/src/components/ChatMarkdown.tsx | 2 +-
.../chat/MermaidDiagram.browser.test.tsx | 66 --
.../src/components/chat/MermaidDiagram.tsx | 357 +++-----
apps/web/src/index.css | 14 +
pnpm-lock.yaml | 793 +-----------------
6 files changed, 159 insertions(+), 1075 deletions(-)
delete mode 100644 apps/web/src/components/chat/MermaidDiagram.browser.test.tsx
diff --git a/apps/web/package.json b/apps/web/package.json
index 09107e5d126d8..ffe45b138f3b4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -63,8 +63,8 @@
"jszip": "3.10.1",
"lucide": "^0.564.0",
"lucide-react": "^0.564.0",
- "mermaid": "^11.17.2",
"morphicons": "^1.7.1",
+ "pele": "^1.6.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index a608ecb0298b5..7ae052968cf69 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -1080,7 +1080,7 @@ function MarkdownMermaidCodeBlock({
}
>
-
+
{searching ? (
diff --git a/apps/web/src/components/chat/MermaidDiagram.browser.test.tsx b/apps/web/src/components/chat/MermaidDiagram.browser.test.tsx
deleted file mode 100644
index 7d65c480da5bc..0000000000000
--- a/apps/web/src/components/chat/MermaidDiagram.browser.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { describe, expect, it } from "vite-plus/test";
-
-import { sanitizeMermaidSvg } from "./MermaidDiagram";
-
-// The label markup Mermaid's sequence diagrams emit for $$...$$ text.
-function noteSvg(label: string) {
- return ``;
-}
-
-function sanitize(svg: string) {
- const host = document.createElement("div");
- host.innerHTML = sanitizeMermaidSvg(svg);
- return host;
-}
-
-describe("sanitizeMermaidSvg", () => {
- it("keeps the MathML Mermaid renders for math labels", () => {
- const host = sanitize(
- noteSvg(
- '',
- ),
- );
-
- const math = host.querySelector(".katex > math");
- expect(math?.namespaceURI).toBe("http://www.w3.org/1998/Math/MathML");
- expect(math?.getAttribute("display")).toBe("block");
- expect(math?.querySelector("mfrac")?.textContent).toBe("ddx");
- expect(math?.querySelector("msqrt")?.textContent).toBe("2");
- expect(math?.querySelector("mi[mathvariant='bold']")?.textContent).toBe("v");
- expect(math?.querySelectorAll("mo[fence='true']")).toHaveLength(2);
- });
-
- it("keeps spaced math labels parseable as an SVG image", () => {
- // KaTeX renders \text{total cost} with a no-break space.
- const svg = sanitizeMermaidSvg(noteSvg(""));
-
- const image = new DOMParser().parseFromString(svg, "image/svg+xml");
- expect(image.documentElement.localName).toBe("svg");
- expect(image.querySelector("mtext")?.textContent).toBe("total cost");
- });
-
- it("strips links, scripts, and MathML outside what KaTeX emits", () => {
- const host = sanitize(
- noteSvg(
- '',
- ),
- );
-
- const math = host.querySelector("math");
- expect(math).not.toBeNull();
- expect(math?.querySelector("mi")?.textContent).toBe("x");
- expect(host.querySelector("[href], [onclick], [src], [onerror]")).toBeNull();
- expect(host.querySelector("maction, mglyph, annotation-xml, annotation, semantics, img")).toBe(
- null,
- );
- });
-
- it("only accepts math as an HTML label and keeps its attributes on MathML", () => {
- const host = sanitize(
- '',
- );
-
- expect(host.querySelector("math, mi")).toBeNull();
- expect(host.querySelector("text")?.getAttributeNames()).toEqual([]);
- });
-});
diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx
index 6ba3bb43dd1cc..49684e90b794c 100644
--- a/apps/web/src/components/chat/MermaidDiagram.tsx
+++ b/apps/web/src/components/chat/MermaidDiagram.tsx
@@ -1,226 +1,89 @@
import DOMPurify from "dompurify";
-import type { Mermaid } from "mermaid";
-import { use, useState } from "react";
+import type { DiagramType } from "pele/lazy";
+import { use, useLayoutEffect, useRef, useState } from "react";
import { Button } from "../ui/button";
-type MermaidRenderResult =
- | { readonly status: "rendered"; readonly svg: string }
- | { readonly status: "error"; readonly message: string; readonly retryable: boolean };
+type Pele = typeof import("pele/lazy");
+type PeleLoad = { readonly status: "loaded"; readonly pele: Pele } | { readonly status: "failed" };
-let mermaidModulePromise: Promise | null = null;
-let renderQueue: Promise = Promise.resolve();
-let nextDiagramId = 0;
-const MAX_CACHED_RENDERS = 64;
-// Pending renders are never evicted, because use() must get the same promise on
-// retry.
-const renderCache = new Map>();
-const settledRenders = new WeakSet>();
+let coreLoad: Promise | null = null;
+// Each diagram type is its own chunk, so a chat with one flowchart loads only
+// the flowchart parser and layout.
+const diagramLoads = new Map>();
-function loadMermaid(): Promise {
- mermaidModulePromise ??= import("mermaid")
- .then((module) => module.default)
- .catch((error: unknown) => {
- mermaidModulePromise = null;
- throw error;
- });
- return mermaidModulePromise;
+function loadCore(): Promise {
+ coreLoad ??= import("pele/lazy").then(
+ (pele): PeleLoad => ({ status: "loaded", pele }),
+ (): PeleLoad => ({ status: "failed" }),
+ );
+ return coreLoad;
}
-const REMOTE_CSS_URL = /url\(\s*(?!['"]?#)[^)]*\)/gi;
-let purifier: ReturnType | null = null;
-
-// Mermaid renders $$...$$ labels with KaTeX's MathML output. These are KaTeX's
-// MathML element types, without annotation (Mermaid strips it), semantics (only
-// a wrapper, so its content is kept), and mglyph (a trusted-input image).
-const KATEX_MATHML_TAGS = new Set([
- "math",
- "menclose",
- "mfrac",
- "mi",
- "mlabeledtr",
- "mn",
- "mo",
- "mover",
- "mpadded",
- "mphantom",
- "mroot",
- "mrow",
- "mspace",
- "msqrt",
- "mstyle",
- "msub",
- "msubsup",
- "msup",
- "mtable",
- "mtd",
- "mtext",
- "mtr",
- "munder",
- "munderover",
-]);
-// Presentation attributes KaTeX sets on those elements. The existing profiles
-// already cover class, style, width, height, display, and xmlns.
-const KATEX_MATHML_ATTRIBUTES = new Set([
- "accent",
- "accentunder",
- "columnalign",
- "columnlines",
- "columnspacing",
- "depth",
- "displaystyle",
- "fence",
- "largeop",
- "linebreak",
- "linethickness",
- "lspace",
- "mathbackground",
- "mathcolor",
- "mathsize",
- "mathvariant",
- "maxsize",
- "minsize",
- "notation",
- "rowlines",
- "rowspacing",
- "rspace",
- "scriptlevel",
- "separator",
- "stretchy",
- "voffset",
-]);
-
-// Diagrams can come from untrusted PR descriptions, so strip anything that can
-// navigate, run script, or fetch remote content on top of Mermaid's own strict
-// sanitization.
-export function sanitizeMermaidSvg(svg: string): string {
- if (!purifier) {
- purifier = DOMPurify(window);
- purifier.addHook("uponSanitizeElement", (node, data) => {
- if (data.tagName === "style" && node.textContent) {
- node.textContent = node.textContent.replace(REMOTE_CSS_URL, "none");
- }
- });
- purifier.addHook("uponSanitizeAttribute", (_node, data) => {
- if (data.attrName === "style")
- data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none");
- });
+function loadDiagramType(pele: Pele, type: DiagramType): Promise {
+ let load = diagramLoads.get(type);
+ if (!load) {
+ load = pele.load(type).then(
+ (): PeleLoad => ({ status: "loaded", pele }),
+ (): PeleLoad => ({ status: "failed" }),
+ );
+ diagramLoads.set(type, load);
}
- const sanitized = purifier.sanitize(svg, {
- // DOMPurify still only accepts