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
6 changes: 6 additions & 0 deletions .changeset/lucky-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'rspress-plugin-mermaid': patch
---

Sanitizing the React `useId()` render id, skipping redundant re-renders, and resolving the
component path from `import.meta.dirname` under ESM.
81 changes: 49 additions & 32 deletions packages/rspress-plugin-mermaid/components/MermaidRender.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,67 @@
import React, { useEffect, useId, useState } from 'react';
import React, { useEffect, useId, useRef, useState } from 'react';

import mermaid, { type MermaidConfig } from 'mermaid';

import type { MermaidRendererProps } from '../src/typings';
interface MermaidRendererProps {
code: string;
config?: MermaidConfig;
}

const MermaidRenderer: React.FC<MermaidRendererProps> = (props) => {
const { code, config = {} } = props;

const id = useId();
// useId() may contain characters that must not leak into mermaid's render id
// (it ends up in the SVG id and url(#...) marker references), e.g. ":" in
// React 18, "«»" in React 19.0-19.1, "_"-wrapped forms in React 19.2+.
const id = useId().replace(/[^a-zA-Z0-9_-]/g, '');

const [svg, setSvg] = useState('');

const [renderError, setRenderError] = useState(false);

const renderMermaid2SVG = React.useCallback(async () => {
// https://github.com/mermaid-js/mermaid/blob/1b40f552b20df4ab99a986dd58c9d254b3bfd7bc/packages/mermaid/src/docs/.vitepress/theme/Mermaid.vue#L53
const hasDarkClass = document.documentElement.classList.contains('dark');

const mermaidConfig: MermaidConfig = {
securityLevel: 'loose',
startOnLoad: false,
theme: hasDarkClass ? 'dark' : 'default',
...config,
};

try {
mermaid.initialize(mermaidConfig);

const { svg } = await mermaid.render(
id.replace(/:/g, ''),
code as string,
);

setSvg(svg);
} catch (error) {
setRenderError(true);
}
}, [code, config, id]);
// Skip re-renders unless the diagram source or light/dark theme changed.
// Separate refs compare prop identity directly, so a long diagram source is
// never re-allocated into a combined key.
const lastTheme = useRef<string | null>(null);
const lastCode = useRef<string | null>(null);

useEffect(() => {
renderMermaid2SVG();
}, [renderMermaid2SVG]);
// Concurrent calls are safe: mermaid queues render() calls internally
// and runs them serially.
const render = async () => {
const theme = document.documentElement.classList.contains('dark')
? 'dark'
: 'default';

if (lastTheme.current === theme && lastCode.current === code) {
return;
}

lastTheme.current = theme;
lastCode.current = code;

const mermaidConfig: MermaidConfig = {
securityLevel: 'loose',
startOnLoad: false,
theme,
...config,
};

try {
mermaid.initialize(mermaidConfig);
const { svg } = await mermaid.render(id, code);
setSvg(svg);
setRenderError(false);
} catch (error) {
lastTheme.current = null;
lastCode.current = null;
setRenderError(true);
}
};

useEffect(() => {
render();
const observer = new MutationObserver(() => {
renderMermaid2SVG();
render();
});

observer.observe(document.documentElement, {
Expand All @@ -55,7 +72,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = (props) => {
return () => {
observer.disconnect();
};
}, [renderMermaid2SVG]);
}, [code, config, id]);

return (
<>
Expand Down
11 changes: 11 additions & 0 deletions packages/rspress-plugin-mermaid/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,14 @@ flowchart TD
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
```

```mermaid
sequenceDiagram
Alice->>Bob: Hello Bob, how are you?
Bob-->>John: How about you John?
Bob--x Alice: I am good thanks!
```

```mermaid
this-is-not-a-valid-diagram-type
```
55 changes: 53 additions & 2 deletions packages/rspress-plugin-mermaid/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,68 @@ describe('rspress-plugin-mermaid', () => {
test('renders the diagram as SVG', async ({ page }) => {
await page.goto(pageUrl());

await expect(page.locator('svg[id^="_r_"]')).toBeVisible();
await expect(page.locator('svg[id^="_r_"]').first()).toBeVisible();
await expect(page.locator('code.language-mermaid')).toHaveCount(0);
});

// Covers preservation of node and edge-label content during rendering.
test('renders diagram labels', async ({ page }) => {
await page.goto(pageUrl());

const diagram = page.locator('svg[id^="_r_"]');
const diagram = page.locator('svg[id^="_r_"]').first();
await expect(diagram).toContainText('Christmas');
await expect(diagram).toContainText('Get money');
await expect(diagram).toContainText('Go shopping');
});

// Covers concurrent renders: two renderer instances mount at the same time;
// mermaid's internal render queue serializes them and both must produce
// correct output.
test('renders multiple diagrams concurrently', async ({ page }) => {
await page.goto(pageUrl());

await expect(
page.locator('svg[id^="_r_"]', { hasText: 'Christmas' }),
).toBeVisible();
await expect(
page.locator('svg[id^="_r_"]', { hasText: 'Alice' }),
).toBeVisible();
});

// Covers the MutationObserver path: injecting the dark class must re-render
// the diagram with the dark theme.
test('re-renders on dark mode toggle', async ({ page }) => {
await page.goto(pageUrl());

const diagram = page.locator('svg[id^="_r_"]').first();
await expect(diagram).toBeVisible();

const lightMarkup = await diagram.evaluate((element) => element.outerHTML);

await page.evaluate(() => document.documentElement.classList.add('dark'));

await expect
.poll(async () =>
page
.locator('svg[id^="_r_"]')
.first()
.evaluate((element) => element.outerHTML),
)
.not.toBe(lightMarkup);
});

// Covers the error path: an invalid diagram renders nothing and valid
// diagrams keep rendering.
test('skips invalid diagrams without breaking valid ones', async ({
page,
}) => {
await page.goto(pageUrl());

await expect(
page.locator('svg[id^="_r_"]', { hasText: 'Christmas' }),
).toBeVisible();
await expect(
page.locator('svg', { hasText: 'this-is-not-a-valid-diagram-type' }),
).toHaveCount(0);
});
});
7 changes: 2 additions & 5 deletions packages/rspress-plugin-mermaid/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import path from 'node:path';

import {
PresetConfigMutator,
RemarkCodeBlockToGlobalComponentPluginFactory,
} from 'rspress-plugin-devkit';
import { RemarkCodeBlockToGlobalComponentPluginFactory } from 'rspress-plugin-devkit';

import type { RspressPlugin } from '@rspress/core';
import type { MermaidConfig } from 'mermaid';
Expand All @@ -23,7 +20,7 @@ export default function rspressPluginMermaid(
{
lang: 'mermaid',
componentPath: path.join(
__dirname,
import.meta.dirname,
'../components',
'MermaidRender.tsx',
),
Expand Down