Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions scripts/perf/firstLoadJs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions src/browser/features/Messages/MarkdownComponents.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -494,7 +499,11 @@ export const markdownComponents = {

// Handle mermaid diagrams specially
if (!isInline && language === "mermaid") {
return <Mermaid chart={childString} />;
return (
<LazyFeature name="Diagram" fallback={<MermaidPendingFrame />}>
<Mermaid chart={childString} />
</LazyFeature>
);
}

// Code blocks with language - use async Shiki highlighting
Expand Down
26 changes: 26 additions & 0 deletions src/browser/features/Messages/Mermaid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ThemeProvider forcedTheme="dark">
<MarkdownRenderer content={"```mermaid\ngraph TD\nA-->B\n```"} />
</ThemeProvider>
);

// The chunk has not loaded yet: no zoom controls, but the same box Mermaid reserves.
const pending = view.container.querySelector<HTMLElement>(".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<HTMLElement>(".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(
Expand Down
33 changes: 10 additions & 23 deletions src/browser/features/Messages/Mermaid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -395,15 +400,7 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {

return (
<>
<div
style={{
position: "relative",
margin: "1em 0",
background: "var(--color-code-bg)",
borderRadius: "4px",
padding: "16px",
}}
>
<div style={MERMAID_FRAME_STYLE}>
<div
style={{
position: "absolute",
Expand Down Expand Up @@ -437,26 +434,16 @@ export const Mermaid: React.FC<{ chart: string }> = ({ 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}
</div>
</div>
{isModalOpen && (
Expand Down
41 changes: 41 additions & 0 deletions src/browser/features/Messages/MermaidPendingFrame.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={MERMAID_FRAME_STYLE}>
<div
className="mermaid-container"
style={{ ...MERMAID_CONTAINER_STYLE, ...MERMAID_PENDING_STYLE }}
>
{MERMAID_PENDING_TEXT}
</div>
</div>
);
}
15 changes: 12 additions & 3 deletions src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -192,7 +199,9 @@ export function ArtifactViewer(props: {
case "mermaid":
return (
<div className="p-3">
<Mermaid chart={result.content} />
<LazyFeature name="Diagram" fallback={<MermaidPendingFrame />}>
<Mermaid chart={result.content} />
</LazyFeature>
</div>
);
case "diff":
Expand Down
Loading