From f0c00e7306e83fdf78400248f6465caecc65b5ae Mon Sep 17 00:00:00 2001 From: matt Date: Sun, 5 Apr 2026 16:43:43 +0900 Subject: [PATCH] perf: lazy-load mermaid to reduce main bundle size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mermaid was statically imported, bundling ~900KB into the main renderer chunk. This caused V8 parse/compile overhead and GC pressure on every interaction in the production build (dev mode was unaffected since Vite serves modules on-demand). - Dynamic import mermaid in MermaidViewer.tsx (loaded on first diagram render) - React.lazy for MermaidViewer in markdownComponents.tsx and MarkdownViewer.tsx - Replace component type check with AST-based language-mermaid class detection Main bundle: 2,740KB → 1,840KB Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/chat/markdownComponents.tsx | 30 +++++++++----- .../chat/viewers/MarkdownViewer.tsx | 19 ++++++--- .../components/chat/viewers/MermaidViewer.tsx | 39 ++++++++++++------- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index 8117d722..03ae8b58 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -4,7 +4,9 @@ import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { PROSE_BODY } from '@renderer/constants/cssVariables'; -import { MermaidViewer } from './viewers/MermaidViewer'; +const MermaidViewer = React.lazy(() => + import('./viewers/MermaidViewer').then((m) => ({ default: m.MermaidViewer })) +); import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; import type { Components } from 'react-markdown'; @@ -129,7 +131,11 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo const text = content.replace(/\n$/, ''); if (lang === 'mermaid') { - return ; + return ( + {text}}> + + + ); } return ( @@ -153,18 +159,22 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo }, // Code blocks — skip
 wrapper for mermaid diagrams, with copy button
-    pre: ({ children }) => {
-      const child = React.Children.only(children) as React.ReactElement;
-      if (child?.type === MermaidViewer) {
+    pre: ({ children, node }) => {
+      // Detect mermaid: check if the  child has class "language-mermaid"
+      const codeEl = node?.children?.find((c) => 'tagName' in c && c.tagName === 'code') as
+        | { properties?: { className?: string[] } }
+        | undefined;
+      const isMermaid = codeEl?.properties?.className?.includes('language-mermaid');
+      if (isMermaid) {
         return children as React.ReactElement;
       }
 
       // Extract text from nested  children for the copy button
-      const extractText = (node: React.ReactNode): string => {
-        if (typeof node === 'string') return node;
-        if (Array.isArray(node)) return node.map(extractText).join('');
-        if (React.isValidElement(node) && node.props) {
-          const props = node.props as { children?: React.ReactNode };
+      const extractText = (child: React.ReactNode): string => {
+        if (typeof child === 'string') return child;
+        if (Array.isArray(child)) return child.map(extractText).join('');
+        if (React.isValidElement(child) && child.props) {
+          const props = child.props as { children?: React.ReactNode };
           return extractText(props.children);
         }
         return '';
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index 21e9b339..9e82b0a7 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -33,7 +33,9 @@ import {
   highlightSearchInChildren,
   type SearchContext,
 } from '../searchHighlightUtils';
-import { MermaidViewer } from '../viewers/MermaidViewer';
+const MermaidViewer = React.lazy(() =>
+  import('../viewers/MermaidViewer').then((m) => ({ default: m.MermaidViewer }))
+);
 import { highlightLine } from '../viewers/syntaxHighlighter';
 
 // =============================================================================
@@ -162,7 +164,11 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
         const text = raw.replace(/\n$/, '');
 
         if (lang === 'mermaid') {
-          return ;
+          return (
+            {text}}>
+              
+            
+          );
         }
 
         const lines = text.split('\n');
@@ -192,9 +198,12 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
     },
 
     // Code blocks — skip 
 wrapper for mermaid diagrams
-    pre: ({ children }) => {
-      const child = React.Children.only(children) as React.ReactElement;
-      if (child?.type === MermaidViewer) {
+    pre: ({ children, node }) => {
+      const codeEl = node?.children?.find((c) => 'tagName' in c && c.tagName === 'code') as
+        | { properties?: { className?: string[] } }
+        | undefined;
+      const isMermaid = codeEl?.properties?.className?.includes('language-mermaid');
+      if (isMermaid) {
         return children as React.ReactElement;
       }
       return (
diff --git a/src/renderer/components/chat/viewers/MermaidViewer.tsx b/src/renderer/components/chat/viewers/MermaidViewer.tsx
index 02459918..92a73bbe 100644
--- a/src/renderer/components/chat/viewers/MermaidViewer.tsx
+++ b/src/renderer/components/chat/viewers/MermaidViewer.tsx
@@ -11,26 +11,37 @@ import {
 } from '@renderer/constants/cssVariables';
 import { useTheme } from '@renderer/hooks/useTheme';
 import { Code, GitBranch } from 'lucide-react';
-import mermaid from 'mermaid';
+
+import type mermaidApi from 'mermaid';
 
 // =============================================================================
-// Mermaid initialization
+// Mermaid initialization (lazy-loaded to keep it out of the main bundle)
 // =============================================================================
 
+let mermaidInstance: typeof mermaidApi | null = null;
 let lastMermaidTheme: 'dark' | 'default' | null = null;
 
-function ensureMermaidInit(isDark: boolean): void {
+async function getMermaid(): Promise {
+  if (!mermaidInstance) {
+    const mod = await import('mermaid');
+    mermaidInstance = mod.default;
+  }
+  return mermaidInstance;
+}
+
+async function ensureMermaidInit(isDark: boolean): Promise {
+  const m = await getMermaid();
   const theme: 'dark' | 'default' = isDark ? 'dark' : 'default';
-  if (lastMermaidTheme === theme) {
-    return;
+  if (lastMermaidTheme !== theme) {
+    m.initialize({
+      startOnLoad: false,
+      theme,
+      securityLevel: 'strict',
+      fontFamily: 'ui-sans-serif, system-ui, sans-serif',
+    });
+    lastMermaidTheme = theme;
   }
-  mermaid.initialize({
-    startOnLoad: false,
-    theme,
-    securityLevel: 'strict',
-    fontFamily: 'ui-sans-serif, system-ui, sans-serif',
-  });
-  lastMermaidTheme = theme;
+  return m;
 }
 
 // =============================================================================
@@ -53,9 +64,9 @@ export const MermaidViewer: React.FC = ({ code }) => {
     let cancelled = false;
     const render = async (): Promise => {
       try {
-        ensureMermaidInit(isDark);
+        const m = await ensureMermaidInit(isDark);
         const id = `mermaid-${uniqueId}`;
-        const { svg: rendered } = await mermaid.render(id, code);
+        const { svg: rendered } = await m.render(id, code);
         if (!cancelled) {
           setSvg(rendered);
           setError(null);