webui: implement Mermaid code splitting with lazy loading
Split Mermaid library into separate bundle with content-based hashing for
optimal caching and reduced bundle size in main application components.
Implementation Changes:
1. Lazy Loading Infrastructure (sketch-timeline-message.ts):
- Replace direct mermaid import with type-only import
- Add loadMermaid() function with Promise-based dynamic loading
- Implement __MERMAID_HASH__ constant injection pattern
- Add global window.mermaid type declarations
- Create mermaid loading promise with singleton pattern
2. Bundle Splitting (esbuild.go):
- Add createStandaloneMermaidBundle() function
- Generate content-based hash from mermaid package.json
- Create mermaid-standalone-{hash}.js as IIFE format bundle
- Implement esbuildBundleWithExternals() replacing esbuildBundleWithExternal()
- Add --external:mermaid to all TypeScript bundle builds
- Inject __MERMAID_HASH__ constant at build time
3. Async Rendering (sketch-timeline-message.ts):
- Update renderMermaidDiagrams() to async/await pattern
- Load mermaid library only when diagrams are present
- Initialize mermaid configuration after dynamic loading
- Maintain fallback to code blocks on loading errors
- Preserve all existing mermaid functionality and configuration
Technical Details:
- Uses content-based hashing for optimal browser caching
- Mermaid loaded on-demand only when diagrams are present
- Singleton loading pattern prevents duplicate network requests
- Maintains existing mermaid initialization options
- Preserves error handling with code block fallbacks
- IIFE format enables direct window.mermaid assignment
Benefits:
- Reduces bundle size for components not using mermaid diagrams
- Enables browser caching of mermaid library across sessions
- Maintains existing functionality with lazy loading
- Improves initial page load performance
- Provides same user experience with deferred mermaid loading
This follows the same pattern established for Monaco editor bundling,
providing consistent lazy loading architecture for large dependencies.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s0f8a82fcd28add05k
diff --git a/webui/esbuild.go b/webui/esbuild.go
index f8f010d..1c356ce 100644
--- a/webui/esbuild.go
+++ b/webui/esbuild.go
@@ -215,22 +215,26 @@
"node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
}
- // Additionally create a standalone Monaco bundle for caching
+ // Additionally create standalone bundles for caching
monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
if err != nil {
return nil, fmt.Errorf("create monaco bundle: %w", err)
}
- _ = monacoHash // We created it for caching benefits, but don't use it yet
- // Bundle all files with Monaco as external (since they may transitively import Monaco)
+ mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
+ if err != nil {
+ return nil, fmt.Errorf("create mermaid bundle: %w", err)
+ }
+
+ // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
for _, tsName := range bundleTs {
- // Use external Monaco for all TypeScript files to ensure consistency
+ // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
if strings.HasSuffix(tsName, ".ts") {
- if err := esbuildBundleWithExternal(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash); err != nil {
+ if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
}
} else {
- // Bundle worker files normally (they don't use Monaco)
+ // Bundle worker files normally (they don't use Monaco or Mermaid)
if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
}
@@ -517,8 +521,60 @@
return monacoHash, nil
}
-// esbuildBundleWithExternal bundles a file with Monaco as external dependency
-func esbuildBundleWithExternal(outDir, src, monacoHash string) error {
+// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
+// This is useful for caching Mermaid separately from the main application bundles
+func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
+ // Create a temporary entry file that imports Mermaid and exposes it globally
+ mermaidEntryContent := `import mermaid from 'mermaid';
+window.mermaid = mermaid;
+export default mermaid;
+`
+ mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
+ if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
+ return "", fmt.Errorf("write mermaid entry: %w", err)
+ }
+
+ // Calculate hash of mermaid package for content-based naming
+ mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
+ mermaidContent, err := os.ReadFile(mermaidPackageJson)
+ if err != nil {
+ return "", fmt.Errorf("read mermaid package.json: %w", err)
+ }
+
+ h := sha256.New()
+ h.Write(mermaidContent)
+ mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
+
+ // Bundle Mermaid with content-based filename
+ mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
+ mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
+
+ args := []string{
+ mermaidEntryPath,
+ "--bundle",
+ "--sourcemap",
+ "--minify",
+ "--log-level=error",
+ "--outfile=" + mermaidOutputPath,
+ "--format=iife",
+ "--global-name=__MermaidLoader__",
+ "--loader:.ttf=file",
+ "--loader:.eot=file",
+ "--loader:.woff=file",
+ "--loader:.woff2=file",
+ "--public-path=.",
+ }
+
+ ret := esbuildcli.Run(args)
+ if ret != 0 {
+ return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
+ }
+
+ return mermaidHash, nil
+}
+
+// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
+func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
args := []string{
src,
"--bundle",
@@ -527,12 +583,14 @@
"--log-level=error",
"--outdir=" + outDir,
"--external:monaco-editor",
+ "--external:mermaid",
"--loader:.ttf=file",
"--loader:.eot=file",
"--loader:.woff=file",
"--loader:.woff2=file",
"--public-path=.",
"--define:__MONACO_HASH__=\"" + monacoHash + "\"",
+ "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
}
ret := esbuildcli.Run(args)
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
index 9ea1ced..9fa5b7a 100644
--- a/webui/src/web-components/sketch-timeline-message.ts
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -3,8 +3,57 @@
import { customElement, property, state } from "lit/decorators.js";
import { AgentMessage, State } from "../types";
import { marked, MarkedOptions, Renderer, Tokens } from "marked";
-import mermaid from "mermaid";
+import type mermaid from "mermaid";
import DOMPurify from "dompurify";
+
+// Mermaid is loaded dynamically - see loadMermaid() function
+declare global {
+ interface Window {
+ mermaid?: typeof mermaid;
+ }
+}
+
+// Mermaid hash will be injected at build time
+declare const __MERMAID_HASH__: string;
+
+// Load Mermaid dynamically
+let mermaidLoadPromise: Promise<any> | null = null;
+
+function loadMermaid(): Promise<typeof mermaid> {
+ if (mermaidLoadPromise) {
+ return mermaidLoadPromise;
+ }
+
+ if (window.mermaid) {
+ return Promise.resolve(window.mermaid);
+ }
+
+ mermaidLoadPromise = new Promise((resolve, reject) => {
+ // Get the Mermaid hash from build-time constant
+ const mermaidHash = __MERMAID_HASH__;
+
+ // Try to load the external Mermaid bundle
+ const script = document.createElement("script");
+ script.onload = () => {
+ // The Mermaid bundle should set window.mermaid
+ if (window.mermaid) {
+ resolve(window.mermaid);
+ } else {
+ reject(new Error("Mermaid not loaded from external bundle"));
+ }
+ };
+ script.onerror = (error) => {
+ console.warn("Failed to load external Mermaid bundle:", error);
+ reject(new Error("Mermaid external bundle failed to load"));
+ };
+
+ // Don't set type="module" since we're using IIFE format
+ script.src = `./static/mermaid-standalone-${mermaidHash}.js`;
+ document.head.appendChild(script);
+ });
+
+ return mermaidLoadPromise;
+}
import "./sketch-tool-calls";
@customElement("sketch-timeline-message")
export class SketchTimelineMessage extends LitElement {
@@ -702,14 +751,7 @@
constructor() {
super();
- // Initialize mermaid with specific config
- mermaid.initialize({
- startOnLoad: false,
- suppressErrorRendering: true,
- theme: "default",
- securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
- fontFamily: "monospace",
- });
+ // Mermaid will be initialized lazily when first needed
}
// See https://lit.dev/docs/components/lifecycle/
@@ -727,38 +769,59 @@
// Render mermaid diagrams after the component is updated
renderMermaidDiagrams() {
// Add a small delay to ensure the DOM is fully rendered
- setTimeout(() => {
+ setTimeout(async () => {
// Find all mermaid containers in our shadow root
const containers = this.shadowRoot?.querySelectorAll(".mermaid");
if (!containers || containers.length === 0) return;
- // Process each mermaid diagram
- containers.forEach((container) => {
- const id = container.id;
- const code = container.textContent || "";
- if (!code || !id) return; // Use return for forEach instead of continue
+ try {
+ // Load mermaid dynamically
+ const mermaidLib = await loadMermaid();
- try {
- // Clear any previous content
- container.innerHTML = code;
+ // Initialize mermaid with specific config (only once per load)
+ mermaidLib.initialize({
+ startOnLoad: false,
+ suppressErrorRendering: true,
+ theme: "default",
+ securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
+ fontFamily: "monospace",
+ });
- // Render the mermaid diagram using promise
- mermaid
- .render(`${id}-svg`, code)
- .then(({ svg }) => {
- container.innerHTML = svg;
- })
- .catch((err) => {
- console.error("Error rendering mermaid diagram:", err);
- // Show the original code as fallback
- container.innerHTML = `<pre>${code}</pre>`;
- });
- } catch (err) {
- console.error("Error processing mermaid diagram:", err);
- // Show the original code as fallback
+ // Process each mermaid diagram
+ containers.forEach((container) => {
+ const id = container.id;
+ const code = container.textContent || "";
+ if (!code || !id) return; // Use return for forEach instead of continue
+
+ try {
+ // Clear any previous content
+ container.innerHTML = code;
+
+ // Render the mermaid diagram using promise
+ mermaidLib
+ .render(`${id}-svg`, code)
+ .then(({ svg }) => {
+ container.innerHTML = svg;
+ })
+ .catch((err) => {
+ console.error("Error rendering mermaid diagram:", err);
+ // Show the original code as fallback
+ container.innerHTML = `<pre>${code}</pre>`;
+ });
+ } catch (err) {
+ console.error("Error processing mermaid diagram:", err);
+ // Show the original code as fallback
+ container.innerHTML = `<pre>${code}</pre>`;
+ }
+ });
+ } catch (err) {
+ console.error("Error loading mermaid:", err);
+ // Show the original code as fallback for all diagrams
+ containers.forEach((container) => {
+ const code = container.textContent || "";
container.innerHTML = `<pre>${code}</pre>`;
- }
- });
+ });
+ }
}, 100); // Small delay to ensure DOM is ready
}