Skip to content

Commit 42f1e3a

Browse files
shantanu patilclaude
authored andcommitted
perf: optimize Mermaid — shared theme observer, lazy-load library, viewport-based rendering
- Replace per-instance MutationObserver with a shared module-level observer using a subscriber pattern with 100ms debounce, reducing N observers to 1 - Lazy-load the mermaid library via dynamic import() with singleton caching instead of top-level static import, removing it from the initial bundle - Add IntersectionObserver with 200px rootMargin to defer mermaid.render() until diagrams scroll into the viewport, skipping off-screen diagrams Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5fffc03 commit 42f1e3a

1 file changed

Lines changed: 99 additions & 20 deletions

File tree

src/components/Mermaid.tsx

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,60 @@
11
import React, { useEffect, useRef, useState, useCallback } from 'react';
2-
import mermaid from 'mermaid';
32
import { injectTechLogos } from '@/lib/mermaidLogoInjector';
4-
// We'll use dynamic import for svg-pan-zoom
3+
// We'll use dynamic import for svg-pan-zoom and mermaid (lazy-loaded)
4+
5+
// ============================================================
6+
// Lazy-load mermaid library (avoids bundling ~64MB into initial chunk)
7+
// ============================================================
8+
let mermaidInstance: typeof import('mermaid').default | null = null;
9+
let mermaidLoadPromise: Promise<typeof import('mermaid').default> | null = null;
10+
11+
async function getMermaid() {
12+
if (mermaidInstance) return mermaidInstance;
13+
if (!mermaidLoadPromise) {
14+
mermaidLoadPromise = import('mermaid').then(m => {
15+
mermaidInstance = m.default;
16+
return mermaidInstance;
17+
});
18+
}
19+
return mermaidLoadPromise;
20+
}
21+
22+
// ============================================================
23+
// Shared MutationObserver for theme changes
24+
// Prevents N observers for N diagram instances on a single page
25+
// ============================================================
26+
type ThemeListener = () => void;
27+
const themeListeners = new Set<ThemeListener>();
28+
let sharedObserver: MutationObserver | null = null;
29+
30+
function subscribeToThemeChanges(listener: ThemeListener): () => void {
31+
themeListeners.add(listener);
32+
33+
if (!sharedObserver && typeof document !== 'undefined') {
34+
let debounceTimer: ReturnType<typeof setTimeout>;
35+
sharedObserver = new MutationObserver((mutations) => {
36+
for (const mutation of mutations) {
37+
if (mutation.type === 'attributes' &&
38+
(mutation.attributeName === 'class' || mutation.attributeName === 'data-theme')) {
39+
clearTimeout(debounceTimer);
40+
debounceTimer = setTimeout(() => {
41+
themeListeners.forEach(l => l());
42+
}, 100); // debounce 100ms
43+
break;
44+
}
45+
}
46+
});
47+
sharedObserver.observe(document.documentElement, { attributes: true });
48+
}
49+
50+
return () => {
51+
themeListeners.delete(listener);
52+
if (themeListeners.size === 0 && sharedObserver) {
53+
sharedObserver.disconnect();
54+
sharedObserver = null;
55+
}
56+
};
57+
}
558

659
// Calming color palettes for diagrams — enhanced with multi-color node palette
760
const LIGHT_THEME = {
@@ -104,7 +157,8 @@ function isDarkMode(): boolean {
104157
return el.classList.contains('dark') || el.getAttribute('data-theme') === 'dark';
105158
}
106159

107-
function initializeMermaid(dark: boolean) {
160+
async function initializeMermaid(dark: boolean) {
161+
const mermaid = await getMermaid();
108162
const vars = dark ? DARK_THEME : LIGHT_THEME;
109163
mermaid.initialize({
110164
startOnLoad: false,
@@ -531,16 +585,18 @@ function initializeMermaid(dark: boolean) {
531585
// Track which theme was last initialized to avoid redundant calls
532586
let lastInitializedTheme: boolean | null = null;
533587

534-
function ensureMermaidInitialized(dark: boolean) {
588+
async function ensureMermaidInitialized(dark: boolean) {
535589
if (lastInitializedTheme !== dark) {
536-
initializeMermaid(dark);
590+
await initializeMermaid(dark);
537591
lastInitializedTheme = dark;
538592
}
539593
}
540594

541595
// Initialize with current theme (deferred to avoid SSR issues)
596+
// Note: this is now async but fire-and-forget at module level — the first
597+
// render that calls ensureMermaidInitialized will await the same promise.
542598
if (typeof document !== 'undefined') {
543-
ensureMermaidInitialized(isDarkMode());
599+
void ensureMermaidInitialized(isDarkMode());
544600
}
545601

546602
/**
@@ -892,27 +948,41 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
892948
const [error, setError] = useState<string | null>(null);
893949
const [isFullscreen, setIsFullscreen] = useState(false);
894950
const [themeKey, setThemeKey] = useState(0); // forces re-render on theme change
951+
const [isInView, setIsInView] = useState(false); // viewport-based lazy rendering
895952
const containerRef = useRef<HTMLDivElement>(null);
953+
const outerRef = useRef<HTMLDivElement>(null); // for IntersectionObserver
896954
const idRef = useRef(`mermaid-${++mermaidIdCounter}-${Math.random().toString(36).substring(2, 9)}`);
897955
const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
898956
const selectedNodeRef = useRef<string | null>(null);
899957

900-
// Watch for theme changes (next-themes adds/removes .dark class or data-theme attr on <html>)
958+
// Viewport-based lazy rendering — only render when diagram scrolls into view
959+
useEffect(() => {
960+
if (!outerRef.current) return;
961+
const observer = new IntersectionObserver(
962+
([entry]) => {
963+
if (entry.isIntersecting) {
964+
setIsInView(true);
965+
observer.disconnect();
966+
}
967+
},
968+
{ rootMargin: '200px' } // start loading 200px before visible
969+
);
970+
observer.observe(outerRef.current);
971+
return () => observer.disconnect();
972+
}, []);
973+
974+
// Watch for theme changes via shared observer (one MutationObserver for all instances)
901975
useEffect(() => {
902-
const observer = new MutationObserver(() => {
976+
const unsubscribe = subscribeToThemeChanges(() => {
903977
const dark = isDarkMode();
904978
// Force re-initialization on theme change by resetting tracker
905979
lastInitializedTheme = null;
906-
ensureMermaidInitialized(dark);
980+
void ensureMermaidInitialized(dark);
907981
// Generate a new unique ID for re-render (mermaid caches by ID)
908982
idRef.current = `mermaid-${++mermaidIdCounter}-${Math.random().toString(36).substring(2, 9)}`;
909983
setThemeKey(k => k + 1);
910984
});
911-
observer.observe(document.documentElement, {
912-
attributes: true,
913-
attributeFilter: ['class', 'data-theme'],
914-
});
915-
return () => observer.disconnect();
985+
return unsubscribe;
916986
}, []);
917987

918988
// Inject tech stack logos and annotate nodes after SVG is rendered in the DOM
@@ -993,12 +1063,13 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
9931063

9941064
// Ensure mermaid is initialized with current theme before render
9951065
const dark = isDarkMode();
996-
ensureMermaidInitialized(dark);
1066+
await ensureMermaidInitialized(dark);
9971067

9981068
try {
9991069
setError(null);
10001070
setSvg('');
10011071

1072+
const mermaid = await getMermaid();
10021073
const { svg: renderedSvg } = await mermaid.render(idRef.current, sanitized);
10031074

10041075
if (!isMountedRef.current) return;
@@ -1015,11 +1086,13 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
10151086
}
10161087
}, [chart]);
10171088

1089+
// Only render when the diagram is in the viewport (lazy rendering)
10181090
useEffect(() => {
1091+
if (!isInView) return;
10191092
const isMountedRef = { current: true };
10201093
renderChart(isMountedRef);
10211094
return () => { isMountedRef.current = false; };
1022-
}, [chart, themeKey, renderChart]);
1095+
}, [chart, themeKey, renderChart, isInView]);
10231096

10241097
const handleDiagramClick = () => {
10251098
if (!error && svg) {
@@ -1040,7 +1113,7 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
10401113
if (error) {
10411114
// `error` now holds the sanitized mermaid source for display
10421115
return (
1043-
<div className={`border border-destructive/30 rounded-md p-4 bg-destructive/5 ${className}`}>
1116+
<div ref={outerRef} className={`border border-destructive/30 rounded-md p-4 bg-destructive/5 ${className}`}>
10441117
<div className="flex items-center justify-between mb-3">
10451118
<div className="text-destructive text-sm font-medium flex items-center">
10461119
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -1068,12 +1141,14 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
10681141

10691142
if (!svg) {
10701143
return (
1071-
<div className={`flex justify-center items-center p-6 min-h-[200px] bg-muted/10 rounded-lg border border-border/50 ${className}`}>
1144+
<div ref={outerRef} className={`flex justify-center items-center p-6 min-h-[200px] bg-muted/10 rounded-lg border border-border/50 ${className}`}>
10721145
<div className="flex items-center space-x-2">
10731146
<div className="w-2 h-2 bg-primary/70 rounded-full animate-pulse"></div>
10741147
<div className="w-2 h-2 bg-primary/70 rounded-full animate-pulse delay-75"></div>
10751148
<div className="w-2 h-2 bg-primary/70 rounded-full animate-pulse delay-150"></div>
1076-
<span className="text-muted-foreground text-xs ml-2 font-medium">Rendering diagram...</span>
1149+
<span className="text-muted-foreground text-xs ml-2 font-medium">
1150+
{isInView ? 'Rendering diagram...' : 'Waiting to render...'}
1151+
</span>
10771152
</div>
10781153
</div>
10791154
);
@@ -1082,7 +1157,11 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', zoomingEnabled
10821157
return (
10831158
<>
10841159
<div
1085-
ref={containerRef}
1160+
ref={(node) => {
1161+
// Combine outerRef and containerRef on the same element
1162+
(outerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
1163+
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
1164+
}}
10861165
className={`w-full max-w-full ${zoomingEnabled ? "min-h-[500px] h-[700px] p-4" : ""}`}
10871166
>
10881167
<div

0 commit comments

Comments
 (0)