From cd4f3c8fc8aa131238ac05e6c32c68def9f11877 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 09:10:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20tests:=20keep=20mermaid=20of?= =?UTF-8?q?f=20the=20first=20load=20and=20pin=20the=20pending=20diagram=20?= =?UTF-8?q?frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Mermaid test fails until the markdown renderer lazy-loads Mermaid (Refs #5971). Signed-off-by: Thomas Kosiewski --- scripts/perf/firstLoadJs.ts | 1 + .../features/Messages/Mermaid.test.tsx | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/scripts/perf/firstLoadJs.ts b/scripts/perf/firstLoadJs.ts index a3c8b26cebf..36978f8362e 100644 --- a/scripts/perf/firstLoadJs.ts +++ b/scripts/perf/firstLoadJs.ts @@ -18,6 +18,7 @@ import * as path from "path"; export const FIRST_LOAD_FORBIDDEN_SOURCES: readonly string[] = [ "node_modules/lottie-web/", "node_modules/ghostty-web/", + "node_modules/mermaid/", ]; function fail(message: string): never { diff --git a/src/browser/features/Messages/Mermaid.test.tsx b/src/browser/features/Messages/Mermaid.test.tsx index 1f7ea89aeef..0518a289644 100644 --- a/src/browser/features/Messages/Mermaid.test.tsx +++ b/src/browser/features/Messages/Mermaid.test.tsx @@ -67,6 +67,32 @@ describe("Mermaid layout stability", () => { mermaidRender.mockClear(); }); + // React.lazy caches the resolved module for the whole process, so this must stay the first + // markdown mermaid render: later renders mount Mermaid synchronously (e.g. under `--rerun-each`). + test("markdown shows the pending diagram frame until the Mermaid chunk loads", async () => { + const view = render( + + B\n```"} /> + + ); + + // The chunk has not loaded yet: no zoom controls, but the same box Mermaid reserves. + const pending = view.container.querySelector(".mermaid-container"); + expect(view.queryAllByRole("button")).toHaveLength(0); + expect(pending?.style.minHeight).toBe("300px"); + expect(pending?.textContent).toBe("Rendering diagram..."); + const pendingFrameStyle = pending?.parentElement?.style.cssText; + + expect(await view.findByRole("button", { name: "⤢" })).toBeTruthy(); + await waitFor(() => + expect(view.container.querySelector(".mermaid-container svg")).not.toBeNull() + ); + // Same outer frame before and after the chunk loads, so nothing shifts. + const loaded = view.container.querySelector(".mermaid-container"); + expect(loaded?.parentElement?.style.cssText).toBe(pendingFrameStyle!); + expect(loaded?.style.minHeight).toBe("300px"); + }); + test("copies a selected textless diagram and excludes diagram controls", async () => { const chart = "graph TD\nA-->B"; const view = render( From 57c965695d97f23859ac0b9e38554ca5123a408d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 10 Oct 2026 09:18:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20perf:=20lazy-load=20Mermaid?= =?UTF-8?q?=20diagrams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarkdownComponents and ArtifactViewer load Mermaid with React.lazy inside LazyFeature, so the mermaid package leaves the first load (Refs #5971). The fallback MermaidPendingFrame shares Mermaid's frame and pending styles, so the box does not shift when the chunk arrives. Signed-off-by: Thomas Kosiewski --- .../features/Messages/MarkdownComponents.tsx | 13 +++++- .../features/Messages/Mermaid.test.tsx | 2 +- src/browser/features/Messages/Mermaid.tsx | 33 +++++---------- .../features/Messages/MermaidPendingFrame.tsx | 41 +++++++++++++++++++ .../ArtifactsTab/ArtifactViewer.tsx | 15 +++++-- 5 files changed, 75 insertions(+), 29 deletions(-) create mode 100644 src/browser/features/Messages/MermaidPendingFrame.tsx diff --git a/src/browser/features/Messages/MarkdownComponents.tsx b/src/browser/features/Messages/MarkdownComponents.tsx index 98251d0c7e0..e9d01a9bf7a 100644 --- a/src/browser/features/Messages/MarkdownComponents.tsx +++ b/src/browser/features/Messages/MarkdownComponents.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import React, { useContext, useEffect, useRef, useState } from "react"; import { Play } from "lucide-react"; -import { Mermaid } from "./Mermaid"; +import { MermaidPendingFrame } from "./MermaidPendingFrame"; import { useOptionalMessageListContext } from "./MessageListContext"; import { StreamingContext } from "./StreamingContext"; import { @@ -15,6 +15,11 @@ import { resolveBrowserLocalhostProxyTemplate } from "@/browser/utils/browserLoc import { normalizeLocalhostProxyUrl } from "@/common/utils/localhostProxyUrl"; import { InlineSkillPreviewContext } from "./InlineSkillPreviewContext"; import { INTERNAL_INLINE_SKILL_HREF_PREFIX } from "./inlineSkillMarkdown"; +import { LazyFeature } from "@/browser/components/LazyFeature/LazyFeature"; + +// Lazy so the `mermaid` package stays off the first load (T3, #5971): diagrams render only +// when a message contains one. +const Mermaid = React.lazy(() => import("./Mermaid").then((m) => ({ default: m.Mermaid }))); interface CodeProps { node?: unknown; @@ -494,7 +499,11 @@ export const markdownComponents = { // Handle mermaid diagrams specially if (!isInline && language === "mermaid") { - return ; + return ( + }> + + + ); } // Code blocks with language - use async Shiki highlighting diff --git a/src/browser/features/Messages/Mermaid.test.tsx b/src/browser/features/Messages/Mermaid.test.tsx index 0518a289644..1333641e51d 100644 --- a/src/browser/features/Messages/Mermaid.test.tsx +++ b/src/browser/features/Messages/Mermaid.test.tsx @@ -89,7 +89,7 @@ describe("Mermaid layout stability", () => { ); // Same outer frame before and after the chunk loads, so nothing shifts. const loaded = view.container.querySelector(".mermaid-container"); - expect(loaded?.parentElement?.style.cssText).toBe(pendingFrameStyle!); + expect(loaded?.parentElement?.style.cssText).toBe(pendingFrameStyle); expect(loaded?.style.minHeight).toBe("300px"); }); diff --git a/src/browser/features/Messages/Mermaid.tsx b/src/browser/features/Messages/Mermaid.tsx index 6975e076af6..25c34352c69 100644 --- a/src/browser/features/Messages/Mermaid.tsx +++ b/src/browser/features/Messages/Mermaid.tsx @@ -7,8 +7,13 @@ import { isDesktopViewportFocused } from "@/browser/utils/ui/keybinds"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { transcriptMermaidSources } from "@/browser/utils/messages/transcriptQuoteAttributes"; import { MERMAID_DIAGRAM_ZOOM_KEY } from "@/common/constants/storage"; +import { + MERMAID_CONTAINER_STYLE, + MERMAID_FRAME_STYLE, + MERMAID_PENDING_STYLE, + MERMAID_PENDING_TEXT, +} from "./MermaidPendingFrame"; -const MIN_HEIGHT = 300; const DEFAULT_ZOOM = 1; const MIN_ZOOM = 0.5; const MAX_ZOOM = 3; @@ -395,15 +400,7 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => { return ( <> -
+
= ({ chart }) => { else transcriptMermaidSources.delete(element); }} style={{ - maxWidth: "70%", - margin: "0 auto", + ...MERMAID_CONTAINER_STYLE, ["--diagram-zoom" as string]: `${diagramZoom}`, - minHeight: `${MIN_HEIGHT}px`, - ...(showPendingPlaceholder - ? { - display: "flex", - alignItems: "center", - justifyContent: "center", - color: "var(--color-text-secondary)", - fontStyle: "italic", - } - : {}), + ...(showPendingPlaceholder ? MERMAID_PENDING_STYLE : {}), }} // SECURITY AUDIT: displaySvg is produced by sanitizeMermaidSvg(), which strips // active SVG/HTML content before insertion. React children are used for the // pending placeholder so untrusted chart text is never rendered as HTML. {...(!showPendingPlaceholder ? { dangerouslySetInnerHTML: { __html: displaySvg } } : {})} > - {showPendingPlaceholder ? "Rendering diagram..." : null} + {showPendingPlaceholder ? MERMAID_PENDING_TEXT : null}
{isModalOpen && ( diff --git a/src/browser/features/Messages/MermaidPendingFrame.tsx b/src/browser/features/Messages/MermaidPendingFrame.tsx new file mode 100644 index 00000000000..426c9a1dcd6 --- /dev/null +++ b/src/browser/features/Messages/MermaidPendingFrame.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties } from "react"; + +// Shared by Mermaid's own pending state and the lazy-load fallback, so both reserve the +// same box and nothing shifts when the Mermaid chunk arrives. Keep `mermaid` out of this +// module: it is on the first load (T3, #5971). +export const MERMAID_FRAME_STYLE: CSSProperties = { + position: "relative", + margin: "1em 0", + background: "var(--color-code-bg)", + borderRadius: "4px", + padding: "16px", +}; + +export const MERMAID_CONTAINER_STYLE: CSSProperties = { + maxWidth: "70%", + margin: "0 auto", + minHeight: "300px", +}; + +export const MERMAID_PENDING_STYLE: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "var(--color-text-secondary)", + fontStyle: "italic", +}; + +export const MERMAID_PENDING_TEXT = "Rendering diagram..."; + +export function MermaidPendingFrame() { + return ( +
+
+ {MERMAID_PENDING_TEXT} +
+
+ ); +} diff --git a/src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.tsx b/src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.tsx index 49890e73f86..51c2129a101 100644 --- a/src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.tsx +++ b/src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.tsx @@ -1,6 +1,7 @@ -import type React from "react"; +import React from "react"; import { Check, Copy, Download, ExternalLink } from "lucide-react"; -import { Mermaid } from "@/browser/features/Messages/Mermaid"; +import { LazyFeature } from "@/browser/components/LazyFeature/LazyFeature"; +import { MermaidPendingFrame } from "@/browser/features/Messages/MermaidPendingFrame"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; import type { ArtifactReadResult } from "@/common/orpc/schemas/artifacts"; @@ -20,6 +21,12 @@ import type { ArtifactAnnotationPick } from "./artifactAnnotation"; import type { ArtifactInteractionHandlers } from "./artifactInteractions"; import { useAgentBrowserAvailable } from "./useAgentBrowserAvailable"; +// Lazy so the `mermaid` package stays off the first load (T3, #5971): diagrams render only +// when an artifact or message contains one. +const Mermaid = React.lazy(() => + import("@/browser/features/Messages/Mermaid").then((m) => ({ default: m.Mermaid })) +); + // Every renderer here goes through React elements, so artifact content (agent-written, // therefore untrusted) is always escaped. HTML and SVG only ever render inside // SandboxedArtifactFrame; never route them through dangerouslySetInnerHTML. @@ -192,7 +199,9 @@ export function ArtifactViewer(props: { case "mermaid": return (
- + }> + +
); case "diff":