Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/lazy-mermaid-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": patch
---

perf(mermaid): load the whole Mermaid block lazily, so the chart, its pan-zoom and its download and fullscreen controls stay out of the main bundle until a diagram renders
100 changes: 100 additions & 0 deletions packages/streamdown/__tests__/built-consumer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";

// Bundles an app that imports only `Streamdown` from the built package with
// esbuild (through tsup, with code splitting) and splits what it produced into
// the code the app loads up front and the code only import() reaches. The
// build runs in a child Node process, outside the test's DOM environment.
// Needs `pnpm build` first.

const distEntry = path.resolve(import.meta.dirname, "../dist/index.js");
const tsupEntry = createRequire(import.meta.url).resolve("tsup");

interface MetafileOutput {
entryPoint?: string;
imports: { external?: boolean; kind: string; path: string }[];
}

export interface ConsumerBundle {
deferred: string;
upFront: string;
}

const reachable = (
outputs: Record<string, MetafileOutput>,
roots: string[],
kind: string
): string[] => {
const seen = new Set<string>();
const pending = [...roots];
while (pending.length > 0) {
const file = pending.pop();
if (file === undefined || seen.has(file)) {
continue;
}
seen.add(file);
for (const imported of outputs[file]?.imports ?? []) {
if (!imported.external && imported.kind === kind) {
pending.push(imported.path);
}
}
}
return [...seen];
};

export const bundleConsumer = (workDir: string): ConsumerBundle => {
if (!existsSync(distEntry)) {
throw new Error("Build the package before running this test: pnpm build");
}
writeFileSync(
path.join(workDir, "app.ts"),
`import { Streamdown } from ${JSON.stringify(distEntry)};\nexport { Streamdown };\n`
);
writeFileSync(
path.join(workDir, "build.mjs"),
`import { build } from ${JSON.stringify(pathToFileURL(tsupEntry).href)};
await build({
entry: { app: "app.ts" },
external: [/^[^./]/],
format: ["esm"],
metafile: true,
outDir: "out",
silent: true,
splitting: true,
});
`
);
execFileSync(process.execPath, ["build.mjs"], {
cwd: workDir,
stdio: "pipe",
});

const { outputs } = JSON.parse(
readFileSync(path.join(workDir, "out", "metafile-esm.json"), "utf8")
) as { outputs: Record<string, MetafileOutput> };
const entryOutput = Object.keys(outputs).find((file) =>
outputs[file].entryPoint?.endsWith("app.ts")
);
if (entryOutput === undefined) {
throw new Error("the consumer build produced no entry output");
}
const upFrontFiles = reachable(outputs, [entryOutput], "import-statement");
const dynamicRoots = upFrontFiles.flatMap((file) =>
outputs[file].imports
.filter((imported) => imported.kind === "dynamic-import")
.map((imported) => imported.path)
);
const deferredFiles = reachable(
outputs,
dynamicRoots,
"import-statement"
).filter((file) => !upFrontFiles.includes(file));
const read = (files: string[]) =>
files
.map((file) => readFileSync(path.resolve(workDir, file), "utf8"))
.join("\n");
return { deferred: read(deferredFiles), upFront: read(upFrontFiles) };
};
34 changes: 34 additions & 0 deletions packages/streamdown/__tests__/mermaid-bundle-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { bundleConsumer, type ConsumerBundle } from "./built-consumer";

const mermaidMarkers = {
"block controls": /["']mermaid-block-actions["']/,
chart: /["']data-streamdown["']\s*:\s*["']mermaid["']/,
"fullscreen view": /["']mermaid-fullscreen["']/,
};

describe("Mermaid bundle boundary", () => {
let workDir = "";
let bundle: ConsumerBundle;

beforeAll(() => {
workDir = mkdtempSync(path.join(tmpdir(), "streamdown-consumer-"));
bundle = bundleConsumer(workDir);
}, 60_000);

afterAll(() => {
if (workDir) {
rmSync(workDir, { force: true, recursive: true });
}
});

it.each(
Object.entries(mermaidMarkers)
)("loads the Mermaid %s only through import()", (_name, marker) => {
expect(bundle.upFront).not.toMatch(marker);
expect(bundle.deferred).toMatch(marker);
});
});
82 changes: 17 additions & 65 deletions packages/streamdown/lib/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,19 @@ import { getCopyCallbacks } from "./controls";
import { ImageComponent } from "./image";
import { LinkSafetyModal } from "./link-modal";
import type { ExtraProps, Options } from "./markdown";
import { MermaidDownloadDropdown } from "./mermaid/download-button";
import { MermaidFullscreenButton } from "./mermaid/fullscreen-button";
import { useCustomRenderer, useMermaidPlugin } from "./plugin-context";
import { useCn } from "./prefix-context";
import { Table } from "./table";

const START_LINE_PATTERN = /startLine=(\d+)/;
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;

// Lazy load heavy components
const Mermaid = lazy(() =>
import("./mermaid").then((mod) => ({ default: mod.Mermaid }))
// Lazy load heavy components. The whole Mermaid block is one chunk: its
// fullscreen control renders the chart too, so importing any of the block's
// controls eagerly would pull the chart, pan-zoom and SVG utilities into the
// main bundle.
const MermaidBlock = lazy(() =>
import("./mermaid/block").then((mod) => ({ default: mod.MermaidBlock }))
);

const LANGUAGE_REGEX = /language-([^\s]+)/;
Expand Down Expand Up @@ -835,7 +836,6 @@ const CodeComponent = ({
children,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> &
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: "Code component handles multiple rendering paths for inline code, code blocks, and mermaid diagrams"
ExtraProps & { "data-block"?: string }) => {
const cn = useCn();
// A code element is block-level when it was inside a <pre> element.
Expand Down Expand Up @@ -931,65 +931,17 @@ const CodeComponent = ({

return (
<Suspense fallback={<CodeBlockSkeleton />}>
<div
className={cn(
"group relative my-4 flex w-full flex-col gap-2 rounded-xl border border-border bg-sidebar p-2",
className
)}
data-streamdown="mermaid-block"
>
<div
className={cn(
"flex h-8 items-center text-muted-foreground text-xs"
)}
>
<span className={cn("ml-1 font-mono lowercase")}>mermaid</span>
</div>
{shouldShowMermaidControls ? (
<div
className={cn(
"pointer-events-none absolute top-2 right-2 z-10 flex items-center"
)}
>
<div
className={cn(
"pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"
)}
data-streamdown="mermaid-block-actions"
>
{showDownload ? (
<MermaidDownloadDropdown
chart={code}
config={mermaidContext?.config}
/>
) : null}
{showCopy ? (
<CodeBlockCopyButton
code={code}
{...getCopyCallbacks(controlsConfig, "mermaid")}
/>
) : null}
{showFullscreen ? (
<MermaidFullscreenButton
chart={code}
config={mermaidContext?.config}
/>
) : null}
</div>
</div>
) : null}
<div
className={cn(
"overflow-hidden rounded-md border border-border bg-background"
)}
>
<Mermaid
chart={code}
config={mermaidContext?.config}
showControls={showPanZoomControls}
/>
</div>
</div>
<MermaidBlock
chart={code}
className={className}
config={mermaidContext?.config}
copyCallbacks={getCopyCallbacks(controlsConfig, "mermaid")}
showControls={shouldShowMermaidControls}
showCopy={showCopy}
showDownload={showDownload}
showFullscreen={showFullscreen}
showPanZoomControls={showPanZoomControls}
/>
</Suspense>
);
}
Expand Down
83 changes: 83 additions & 0 deletions packages/streamdown/lib/mermaid/block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { CodeBlockCopyButton } from "../code-block/copy-button";
import type { MermaidConfig } from "../plugin-types";
import { useCn } from "../prefix-context";
import { Mermaid } from ".";
import { MermaidDownloadDropdown } from "./download-button";
import { MermaidFullscreenButton } from "./fullscreen-button";

interface MermaidBlockProps {
chart: string;
className?: string;
config?: MermaidConfig;
copyCallbacks: { onCopy?: () => void; onError?: (error: Error) => void };
showControls: boolean;
showCopy: boolean;
showDownload: boolean;
showFullscreen: boolean;
showPanZoomControls: boolean;
}

export const MermaidBlock = ({
chart,
className,
config,
copyCallbacks,
showControls,
showCopy,
showDownload,
showFullscreen,
showPanZoomControls,
}: MermaidBlockProps) => {
const cn = useCn();

return (
<div
className={cn(
"group relative my-4 flex w-full flex-col gap-2 rounded-xl border border-border bg-sidebar p-2",
className
)}
data-streamdown="mermaid-block"
>
<div
className={cn("flex h-8 items-center text-muted-foreground text-xs")}
>
<span className={cn("ml-1 font-mono lowercase")}>mermaid</span>
</div>
{showControls ? (
<div
className={cn(
"pointer-events-none absolute top-2 right-2 z-10 flex items-center"
)}
>
<div
className={cn(
"pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"
)}
data-streamdown="mermaid-block-actions"
>
{showDownload ? (
<MermaidDownloadDropdown chart={chart} config={config} />
) : null}
{showCopy ? (
<CodeBlockCopyButton code={chart} {...copyCallbacks} />
) : null}
{showFullscreen ? (
<MermaidFullscreenButton chart={chart} config={config} />
) : null}
</div>
</div>
) : null}
<div
className={cn(
"overflow-hidden rounded-md border border-border bg-background"
)}
>
<Mermaid
chart={chart}
config={config}
showControls={showPanZoomControls}
/>
</div>
</div>
);
};
Loading