Skip to content
Merged
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
61 changes: 61 additions & 0 deletions docs/components/mermaid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client';

import { useTheme } from 'next-themes';
import { use, useEffect, useId, useState } from 'react';

export function Mermaid({ chart }: { chart: string }) {
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);

if (!mounted) return;
return <MermaidContent chart={chart} />;
}

const cache = new Map<string, Promise<unknown>>();

function cachePromise<T>(
key: string,
setPromise: () => Promise<T>
): Promise<T> {
const cached = cache.get(key);
if (cached) return cached as Promise<T>;

const promise = setPromise();
cache.set(key, promise);
return promise;
}

function MermaidContent({ chart }: { chart: string }) {
const id = useId();
const { resolvedTheme } = useTheme();
const { default: mermaid } = use(
cachePromise('mermaid', () => import('mermaid'))
);

mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
fontFamily: 'inherit',
themeCSS: 'margin: 1.5rem auto 0;',
theme: resolvedTheme === 'dark' ? 'dark' : 'default',
});

const { svg, bindFunctions } = use(
cachePromise(`${chart}-${resolvedTheme}`, () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cachePromise(`${chart}-${resolvedTheme}`, () => {
cachePromise(`${chart}-${resolvedTheme}-${id}`, () => {

The mermaid component caches rendered diagrams by chart and theme but not by component instance. When the same chart is rendered multiple times, all instances will have the same ID, causing HTML ID conflicts and accessibility issues.

View Details

Analysis

Duplicate HTML IDs in Mermaid component when rendering identical charts

What fails: The MermaidContent component in docs/components/mermaid.tsx caches rendered SVG by chart content and theme, but not by component instance ID. When multiple component instances render identical chart content with the same theme, they all receive the same cached SVG (containing the first instance's ID), resulting in duplicate HTML IDs in the DOM.

How to reproduce:

  1. Render the same Mermaid chart in two separate component instances (e.g., in two MDX pages or components)
  2. Both instances will receive SVGs with the same ID attribute, violating HTML standards and breaking accessibility

Example:

// Two separate component instances with identical chart
<Mermaid chart="flowchart TD\n  A-->B" />
<Mermaid chart="flowchart TD\n  A-->B" />

Result: Both rendered SVGs contain the same ID (e.g., id="__react_useId_123"), creating duplicate IDs in the DOM. The cached SVG from the first instance is reused for the second instance, preventing the second instance from getting its own unique ID via useId().

Expected behavior: Each component instance should receive an SVG with its own unique ID, as generated by React's useId() hook. According to React documentation on useId, each call to useId() should produce a unique, stable identifier. This is particularly important for mermaid diagrams since mermaid itself generates SVG elements with potentially colliding IDs.

Fix: Include the component instance ID in the cache key (line 47) so each instance's render is cached separately: cachePromise(\

return mermaid.render(id, chart.replaceAll('\\n', '\n'));
})
);
Comment on lines +31 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Mermaid diagram may be rendered twice due to theme resolution race condition: once with an undefined theme (cached) and again when the theme actually resolves (with a different cache key).

View Details
📝 Patch Details
diff --git a/docs/components/mermaid.tsx b/docs/components/mermaid.tsx
index 08c3d10..928e7c9 100644
--- a/docs/components/mermaid.tsx
+++ b/docs/components/mermaid.tsx
@@ -31,6 +31,19 @@ function cachePromise<T>(
 function MermaidContent({ chart }: { chart: string }) {
   const id = useId();
   const { resolvedTheme } = useTheme();
+  const [themeResolved, setThemeResolved] = useState(false);
+
+  // Wait for resolvedTheme to be defined before rendering to avoid cache key race conditions
+  useEffect(() => {
+    if (resolvedTheme !== undefined) {
+      setThemeResolved(true);
+    }
+  }, [resolvedTheme]);
+
+  if (!themeResolved) {
+    return null;
+  }
+
   const { default: mermaid } = use(
     cachePromise('mermaid', () => import('mermaid'))
   );

Analysis

Mermaid diagram rendered twice due to resolvedTheme race condition

What fails: The MermaidContent component renders twice when resolvedTheme is initially undefined, causing the mermaid diagram to be rendered with the default theme first, then again with the correct theme. The cache key in cachePromise() changes mid-render from chart-undefined to chart-<actual-theme>, triggering duplicate rendering and potential visual flickering.

How to reproduce:

  1. Use the Mermaid component on any page with dark/light theme support
  2. Navigate to a page with a mermaid diagram
  3. Check browser DevTools: Network tab will show mermaid being imported/initialized twice, Console will show the render called twice with different cache keys
  4. Check React DevTools Profiler: Shows two render passes for MermaidContent

Result: Mermaid's render() function is called twice - once with resolvedTheme = undefined (caching under chart-undefined), then again with the actual theme value (caching under chart-<theme>, creating a new promise and rendering again). The diagram may flicker or render twice visually.

Expected: According to next-themes documentation on Avoid Hydration Mismatch, "many of the values returned from useTheme will be undefined until mounted on the client." The component should wait for resolvedTheme to be defined before rendering, following the documented pattern of using both a mounted check AND verifying resolvedTheme is defined before using it in rendering logic.

Root cause: The parent Mermaid component checks mounted before rendering MermaidContent, but this only ensures the component renders on the client—not that next-themes has resolved the theme. The MermaidContent component calls useTheme() and immediately uses resolvedTheme (which can be undefined) in the cache key and rendering logic, violating next-themes best practices.


return (
<div
ref={(container) => {
if (container) bindFunctions?.(container);
}}
// biome-ignore lint/security/noDangerouslySetInnerHtml: Matching fumadocs + is only running trusted input from mdx files
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
Comment on lines +31 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MermaidContent component uses the use() hook without a wrapping Suspense boundary, which will cause runtime errors when the component attempts to render because use() suspends while waiting for promises to resolve.

View Details
📝 Patch Details
diff --git a/docs/components/mermaid.tsx b/docs/components/mermaid.tsx
index 08c3d10..355e0a8 100644
--- a/docs/components/mermaid.tsx
+++ b/docs/components/mermaid.tsx
@@ -1,5 +1,6 @@
 'use client';
 
+import { Suspense } from 'react';
 import { useTheme } from 'next-themes';
 import { use, useEffect, useId, useState } from 'react';
 
@@ -11,7 +12,11 @@ export function Mermaid({ chart }: { chart: string }) {
   }, []);
 
   if (!mounted) return;
-  return <MermaidContent chart={chart} />;
+  return (
+    <Suspense fallback={<div>Loading diagram...</div>}>
+      <MermaidContent chart={chart} />
+    </Suspense>
+  );
 }
 
 const cache = new Map<string, Promise<unknown>>();

Analysis

use() hook in MermaidContent causes runtime error without Suspense boundary

What fails: The MermaidContent component calls use() with unresolved promises (lines 34-36 for dynamic import, lines 46-50 for mermaid.render) without a wrapping Suspense boundary, violating React 19's requirement for suspending components.

How to reproduce:

  1. Visit any documentation page containing a mermaid diagram (e.g., pages using the Mermaid MDX component exported in mdx-components.tsx)
  2. The component will attempt to render with unresolved dynamic import promises
  3. React 19's use() hook will suspend when unwrapping the unresolved promises from cachePromise()

Result: React throws error because a component suspended while rendering without a Suspense boundary to catch it. Per React's documentation on use(), "When called with a Promise, the use API integrates with Suspense. The component calling use suspends while the Promise passed to use is pending."

Expected: Components using use() with promises must be wrapped in a Suspense boundary to handle the suspension properly. The fix wraps MermaidContent in <Suspense fallback={<div>Loading diagram...</div>}> within the Mermaid component (after hydration check), allowing use() to suspend safely while displaying a loading state.

Loading