From 2d5465295a155afaa7d09720dc0d376a6e7e02f0 Mon Sep 17 00:00:00 2001 From: hangtiancheng <161043261@qq.com> Date: Sat, 5 Sep 2026 13:34:41 +0800 Subject: [PATCH 1/5] fix: Fix rspress-plugin-mermaid render --- .../components/MermaidRender.tsx | 85 ++++++++++++------- packages/rspress-plugin-mermaid/docs/index.md | 11 +++ packages/rspress-plugin-mermaid/index.spec.ts | 55 +++++++++++- packages/rspress-plugin-mermaid/package.json | 1 - packages/rspress-plugin-mermaid/src/index.ts | 3 +- pnpm-lock.yaml | 3 - 6 files changed, 119 insertions(+), 39 deletions(-) diff --git a/packages/rspress-plugin-mermaid/components/MermaidRender.tsx b/packages/rspress-plugin-mermaid/components/MermaidRender.tsx index 3fe8bc8..07b9177 100644 --- a/packages/rspress-plugin-mermaid/components/MermaidRender.tsx +++ b/packages/rspress-plugin-mermaid/components/MermaidRender.tsx @@ -1,50 +1,73 @@ -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'; +// mermaid v10 render() is not reentrant: concurrent renders share temporary +// DOM elements keyed by the render id and corrupt each other's output. +// Serialize all renders through a module-level queue. +let renderQueue: Promise = Promise.resolve(); + +const enqueueRender = (task: () => Promise) => { + renderQueue = renderQueue.then(task, task); +}; + 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]); + const render = () => { + enqueueRender(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 +78,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..cb04ad5 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 the module-level render queue: two renderer instances mount at the + // same time and must both produce correct output despite mermaid's shared + // global render state. + 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 without + // poisoning the shared render queue. + 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/package.json b/packages/rspress-plugin-mermaid/package.json index ca7c9c3..eca98cd 100644 --- a/packages/rspress-plugin-mermaid/package.json +++ b/packages/rspress-plugin-mermaid/package.json @@ -48,7 +48,6 @@ "@rspress/core": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", - "rspress-plugin-mermaid": "workspace:*", "typescript": "catalog:" }, "peerDependencies": { diff --git a/packages/rspress-plugin-mermaid/src/index.ts b/packages/rspress-plugin-mermaid/src/index.ts index 8d24c79..6b88b3b 100644 --- a/packages/rspress-plugin-mermaid/src/index.ts +++ b/packages/rspress-plugin-mermaid/src/index.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { - PresetConfigMutator, RemarkCodeBlockToGlobalComponentPluginFactory, } from 'rspress-plugin-devkit'; @@ -23,7 +22,7 @@ export default function rspressPluginMermaid( { lang: 'mermaid', componentPath: path.join( - __dirname, + import.meta.dirname, '../components', 'MermaidRender.tsx', ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index add6923..94760a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -394,9 +394,6 @@ importers: '@types/react': specifier: 'catalog:' version: 18.3.31 - rspress-plugin-mermaid: - specifier: workspace:* - version: 'link:' typescript: specifier: 'catalog:' version: 6.0.3 From 5a50c46c620e7cbebc34c1a0596ca44548b6e698 Mon Sep 17 00:00:00 2001 From: hangtiancheng <161043261@qq.com> Date: Sat, 5 Sep 2026 13:53:26 +0800 Subject: [PATCH 2/5] fix: Fix rspress-plugin-mermaid types --- packages/rspress-plugin-mermaid/components/MermaidRender.tsx | 5 ++++- packages/rspress-plugin-mermaid/package.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/rspress-plugin-mermaid/components/MermaidRender.tsx b/packages/rspress-plugin-mermaid/components/MermaidRender.tsx index 07b9177..fdb6a39 100644 --- a/packages/rspress-plugin-mermaid/components/MermaidRender.tsx +++ b/packages/rspress-plugin-mermaid/components/MermaidRender.tsx @@ -2,7 +2,10 @@ 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; +} // mermaid v10 render() is not reentrant: concurrent renders share temporary // DOM elements keyed by the render id and corrupt each other's output. diff --git a/packages/rspress-plugin-mermaid/package.json b/packages/rspress-plugin-mermaid/package.json index eca98cd..ca7c9c3 100644 --- a/packages/rspress-plugin-mermaid/package.json +++ b/packages/rspress-plugin-mermaid/package.json @@ -48,6 +48,7 @@ "@rspress/core": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", + "rspress-plugin-mermaid": "workspace:*", "typescript": "catalog:" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94760a9..add6923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -394,6 +394,9 @@ importers: '@types/react': specifier: 'catalog:' version: 18.3.31 + rspress-plugin-mermaid: + specifier: workspace:* + version: 'link:' typescript: specifier: 'catalog:' version: 6.0.3 From e9fcab4de1c497de230317139f4f3fdadbb4503f Mon Sep 17 00:00:00 2001 From: hangtiancheng <161043261@qq.com> Date: Sat, 5 Sep 2026 14:01:48 +0800 Subject: [PATCH 3/5] chore: add changeset for rspress-plugin-mermaid fix Co-Authored-By: Swifty --- .changeset/lucky-donkeys-shave.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/lucky-donkeys-shave.md diff --git a/.changeset/lucky-donkeys-shave.md b/.changeset/lucky-donkeys-shave.md new file mode 100644 index 0000000..6b72150 --- /dev/null +++ b/.changeset/lucky-donkeys-shave.md @@ -0,0 +1,7 @@ +--- +'rspress-plugin-mermaid': patch +--- + +Fix mermaid rendering by serializing renders through a queue, sanitizing the +React `useId()` render id, skipping redundant re-renders, and resolving the +component path from `import.meta.dirname` under ESM. From 238d9baa2dc69874f6c6f9991aaa359b06a932bc Mon Sep 17 00:00:00 2001 From: hangtiancheng <161043261@qq.com> Date: Sat, 5 Sep 2026 14:42:58 +0800 Subject: [PATCH 4/5] refactor: remove redundant render queue from MermaidRenderer mermaid v10 serializes render() calls through its internal execution queue, so the module-level queue in the plugin is unnecessary. Co-Authored-By: Swifty --- .changeset/lucky-donkeys-shave.md | 3 +- .../components/MermaidRender.tsx | 71 ++++++++----------- packages/rspress-plugin-mermaid/index.spec.ts | 10 +-- 3 files changed, 37 insertions(+), 47 deletions(-) diff --git a/.changeset/lucky-donkeys-shave.md b/.changeset/lucky-donkeys-shave.md index 6b72150..7120f01 100644 --- a/.changeset/lucky-donkeys-shave.md +++ b/.changeset/lucky-donkeys-shave.md @@ -2,6 +2,5 @@ 'rspress-plugin-mermaid': patch --- -Fix mermaid rendering by serializing renders through a queue, sanitizing the -React `useId()` render id, skipping redundant re-renders, and resolving the +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 fdb6a39..cad444f 100644 --- a/packages/rspress-plugin-mermaid/components/MermaidRender.tsx +++ b/packages/rspress-plugin-mermaid/components/MermaidRender.tsx @@ -7,15 +7,6 @@ interface MermaidRendererProps { config?: MermaidConfig; } -// mermaid v10 render() is not reentrant: concurrent renders share temporary -// DOM elements keyed by the render id and corrupt each other's output. -// Serialize all renders through a module-level queue. -let renderQueue: Promise = Promise.resolve(); - -const enqueueRender = (task: () => Promise) => { - renderQueue = renderQueue.then(task, task); -}; - const MermaidRenderer: React.FC = (props) => { const { code, config = {} } = props; @@ -35,37 +26,37 @@ const MermaidRenderer: React.FC = (props) => { const lastCode = useRef(null); useEffect(() => { - const render = () => { - enqueueRender(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); - } - }); + // 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); + } }; render(); diff --git a/packages/rspress-plugin-mermaid/index.spec.ts b/packages/rspress-plugin-mermaid/index.spec.ts index cb04ad5..e9c243c 100644 --- a/packages/rspress-plugin-mermaid/index.spec.ts +++ b/packages/rspress-plugin-mermaid/index.spec.ts @@ -22,9 +22,9 @@ describe('rspress-plugin-mermaid', () => { await expect(diagram).toContainText('Go shopping'); }); - // Covers the module-level render queue: two renderer instances mount at the - // same time and must both produce correct output despite mermaid's shared - // global render state. + // 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()); @@ -58,8 +58,8 @@ describe('rspress-plugin-mermaid', () => { .not.toBe(lightMarkup); }); - // Covers the error path: an invalid diagram renders nothing without - // poisoning the shared render queue. + // Covers the error path: an invalid diagram renders nothing and valid + // diagrams keep rendering. test('skips invalid diagrams without breaking valid ones', async ({ page, }) => { From 19830f4ffa11be5836996f142c88de8451e9338f Mon Sep 17 00:00:00 2001 From: hangtiancheng <161043261@qq.com> Date: Mon, 7 Sep 2026 16:32:48 +0800 Subject: [PATCH 5/5] style: format rspress-plugin-mermaid index.ts Co-Authored-By: Swifty --- packages/rspress-plugin-mermaid/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/rspress-plugin-mermaid/src/index.ts b/packages/rspress-plugin-mermaid/src/index.ts index 6b88b3b..bb1546f 100644 --- a/packages/rspress-plugin-mermaid/src/index.ts +++ b/packages/rspress-plugin-mermaid/src/index.ts @@ -1,8 +1,6 @@ import path from 'node:path'; -import { - RemarkCodeBlockToGlobalComponentPluginFactory, -} from 'rspress-plugin-devkit'; +import { RemarkCodeBlockToGlobalComponentPluginFactory } from 'rspress-plugin-devkit'; import type { RspressPlugin } from '@rspress/core'; import type { MermaidConfig } from 'mermaid';