diff --git a/.changeset/lucky-donkeys-shave.md b/.changeset/lucky-donkeys-shave.md new file mode 100644 index 0000000..7120f01 --- /dev/null +++ b/.changeset/lucky-donkeys-shave.md @@ -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. diff --git a/packages/rspress-plugin-mermaid/components/MermaidRender.tsx b/packages/rspress-plugin-mermaid/components/MermaidRender.tsx index 3fe8bc8..cad444f 100644 --- a/packages/rspress-plugin-mermaid/components/MermaidRender.tsx +++ b/packages/rspress-plugin-mermaid/components/MermaidRender.tsx @@ -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 = (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(null); + const lastCode = useRef(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, { @@ -55,7 +72,7 @@ const MermaidRenderer: React.FC = (props) => { return () => { observer.disconnect(); }; - }, [renderMermaid2SVG]); + }, [code, config, id]); return ( <> diff --git a/packages/rspress-plugin-mermaid/docs/index.md b/packages/rspress-plugin-mermaid/docs/index.md index c974a87..40d0958 100644 --- a/packages/rspress-plugin-mermaid/docs/index.md +++ b/packages/rspress-plugin-mermaid/docs/index.md @@ -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 +``` diff --git a/packages/rspress-plugin-mermaid/index.spec.ts b/packages/rspress-plugin-mermaid/index.spec.ts index d4a4714..e9c243c 100644 --- a/packages/rspress-plugin-mermaid/index.spec.ts +++ b/packages/rspress-plugin-mermaid/index.spec.ts @@ -8,7 +8,7 @@ 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); }); @@ -16,9 +16,60 @@ describe('rspress-plugin-mermaid', () => { 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); + }); }); diff --git a/packages/rspress-plugin-mermaid/src/index.ts b/packages/rspress-plugin-mermaid/src/index.ts index 8d24c79..bb1546f 100644 --- a/packages/rspress-plugin-mermaid/src/index.ts +++ b/packages/rspress-plugin-mermaid/src/index.ts @@ -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'; @@ -23,7 +20,7 @@ export default function rspressPluginMermaid( { lang: 'mermaid', componentPath: path.join( - __dirname, + import.meta.dirname, '../components', 'MermaidRender.tsx', ),