11import React , { useEffect , useRef , useState , useCallback } from 'react' ;
2- import mermaid from 'mermaid' ;
32import { 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
760const 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
532586let 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.
542598if ( 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