From e460270f682987e8b4b9bbb7f98db3b837731f71 Mon Sep 17 00:00:00 2001 From: Aryan Sachin Date: Sat, 7 Nov 2026 13:30:23 +0530 Subject: [PATCH] fix(theme-mermaid): serialize initialize and render calls to prevent empty SVGs (#12757) Mermaid is a mutable singleton where initialize() sets global siteConfig. When multiple diagrams on a page mount simultaneously, concurrent initialize() and render() calls can corrupt internal state, leading to empty SVG outputs on page reload. This change: - Serializes Mermaid initialize and render calls through a module-level queue - Ensures queued renders continue even if a preceding diagram throws an error - Adds an unmount cancellation check to avoid setState on unmounted components - Adds unit tests verifying sequential execution and error handling Closes #12757 Signed-off-by: Aryan Sachin --- .../src/__tests__/renderMermaid.test.ts | 167 ++++++++++++++++++ .../src/client/index.ts | 103 +++++++---- 2 files changed, 233 insertions(+), 37 deletions(-) create mode 100644 packages/docusaurus-theme-mermaid/src/__tests__/renderMermaid.test.ts diff --git a/packages/docusaurus-theme-mermaid/src/__tests__/renderMermaid.test.ts b/packages/docusaurus-theme-mermaid/src/__tests__/renderMermaid.test.ts new file mode 100644 index 000000000000..0fb6cdf54eee --- /dev/null +++ b/packages/docusaurus-theme-mermaid/src/__tests__/renderMermaid.test.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {describe, expect, it, vi, beforeEach} from 'vitest'; +import {renderMermaid, resetRenderQueueForTest} from '../client/index'; +import * as loadMermaidModule from '../client/loadMermaid'; +import type {MermaidConfig, RenderResult} from 'mermaid'; + +describe('renderMermaid', () => { + beforeEach(() => { + resetRenderQueueForTest(); + vi.restoreAllMocks(); + }); + + it('serialize concurrent renders so initialize and render never overlap', async () => { + const executionTrace: string[] = []; + let activeRenders = 0; + let maxConcurrentRenders = 0; + + const mockMermaid = { + initialize: vi.fn((config: MermaidConfig) => { + executionTrace.push(`initialize:${config.theme}`); + }), + render: vi.fn(async (id: string, text: string): Promise => { + activeRenders++; + if (activeRenders > maxConcurrentRenders) { + maxConcurrentRenders = activeRenders; + } + executionTrace.push(`render:start:${id}`); + + // Simulate async layout/rendering time + await new Promise((resolve) => setTimeout(resolve, 20)); + + executionTrace.push(`render:end:${id}`); + activeRenders--; + return { + svg: `${text}`, + diagramType: 'flowchart', + }; + }), + }; + + vi.spyOn(loadMermaidModule, 'loadMermaid').mockResolvedValue( + mockMermaid as unknown as typeof loadMermaidModule.loadMermaid extends () => Promise< + infer M + > + ? M + : never, + ); + + // Call 3 renders concurrently without awaiting + const p1 = renderMermaid({ + id: 'd1', + text: 'graph TD; A-->B', + config: {theme: 'default'}, + }); + const p2 = renderMermaid({ + id: 'd2', + text: 'graph TD; C-->D', + config: {theme: 'forest'}, + }); + const p3 = renderMermaid({ + id: 'd3', + text: 'graph TD; E-->F', + config: {theme: 'dark'}, + }); + + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + expect(r1.svg).toBe('graph TD; A-->B'); + expect(r2.svg).toBe('graph TD; C-->D'); + expect(r3.svg).toBe('graph TD; E-->F'); + + // Must never have more than 1 render in flight simultaneously + expect(maxConcurrentRenders).toBe(1); + + // Strict sequential execution: + // initialize -> render start -> render end -> initialize next... + expect(executionTrace).toEqual([ + 'initialize:default', + 'render:start:d1', + 'render:end:d1', + 'initialize:forest', + 'render:start:d2', + 'render:end:d2', + 'initialize:dark', + 'render:start:d3', + 'render:end:d3', + ]); + }); + + it('continue executing queued renders even if an earlier render throws', async () => { + const mockMermaid = { + initialize: vi.fn(), + render: vi.fn(async (id: string, text: string): Promise => { + if (id === 'failing-diagram') { + throw new Error('Syntax error in diagram'); + } + return { + svg: `${text}`, + diagramType: 'flowchart', + }; + }), + }; + + vi.spyOn(loadMermaidModule, 'loadMermaid').mockResolvedValue( + mockMermaid as unknown as typeof loadMermaidModule.loadMermaid extends () => Promise< + infer M + > + ? M + : never, + ); + + const p1 = renderMermaid({ + id: 'failing-diagram', + text: 'syntax error', + config: {theme: 'default'}, + }); + const p2 = renderMermaid({ + id: 'succeeding-diagram', + text: 'graph TD; A-->B', + config: {theme: 'default'}, + }); + + await expect(p1).rejects.toThrow('Syntax error in diagram'); + const r2 = await p2; + expect(r2.svg).toBe('graph TD; A-->B'); + }); + + it('clean up error container from document on error if present', async () => { + const removeFn = vi.fn(); + const querySelectorSpy = vi.fn().mockReturnValue({remove: removeFn}); + vi.stubGlobal('document', {querySelector: querySelectorSpy}); + + const mockMermaid = { + initialize: vi.fn(), + render: vi.fn(async () => { + throw new Error('Render failed'); + }), + }; + + vi.spyOn(loadMermaidModule, 'loadMermaid').mockResolvedValue( + mockMermaid as unknown as typeof loadMermaidModule.loadMermaid extends () => Promise< + infer M + > + ? M + : never, + ); + + await expect( + renderMermaid({ + id: 'error-id', + text: 'invalid', + config: {theme: 'default'}, + }), + ).rejects.toThrow('Render failed'); + + expect(querySelectorSpy).toHaveBeenCalledWith('#derror-id'); + expect(removeFn).toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/docusaurus-theme-mermaid/src/client/index.ts b/packages/docusaurus-theme-mermaid/src/client/index.ts index c850cce7cad4..a4acdcac40d2 100644 --- a/packages/docusaurus-theme-mermaid/src/client/index.ts +++ b/packages/docusaurus-theme-mermaid/src/client/index.ts @@ -32,7 +32,13 @@ export function useMermaidConfig(): MermaidConfig { ); } -async function renderMermaid({ +let renderQueue: Promise = Promise.resolve(); + +export function resetRenderQueueForTest(): void { + renderQueue = Promise.resolve(); +} + +export async function renderMermaid({ id, text, config, @@ -41,36 +47,47 @@ async function renderMermaid({ text: string; config: MermaidConfig; }): Promise { - const mermaid = await loadMermaid(); - - /* - Mermaid API is really weird :s - It is a big mutable singleton with multiple config levels - Note: most recent API type definitions are missing - - There are 2 kind of configs: - - - siteConfig: some kind of global/protected shared config - you can only set with "initialize" - - - config/currentConfig - the config the renderer will use - it is reset to siteConfig before each render - but it can be altered by the mermaid txt content itself through directives - - To use a new mermaid config (on colorMode change for example) we should - update siteConfig, and it can only be done with initialize() - */ - mermaid.initialize(config); - - try { - return await mermaid.render(id, text); - } catch (e) { - // Because Mermaid add a weird SVG/Message to the DOM on error - // https://github.com/mermaid-js/mermaid/issues/3205#issuecomment-1719620183 - document.querySelector(`#d${id}`)?.remove(); - throw e; - } + const run = async (): Promise => { + const mermaid = await loadMermaid(); + + /* + Mermaid API is really weird :s + It is a big mutable singleton with multiple config levels + Note: most recent API type definitions are missing + + There are 2 kind of configs: + + - siteConfig: some kind of global/protected shared config + you can only set with "initialize" + + - config/currentConfig + the config the renderer will use + it is reset to siteConfig before each render + but it can be altered by the mermaid txt content itself through directives + + To use a new mermaid config (on colorMode change for example) we should + update siteConfig, and it can only be done with initialize() + */ + mermaid.initialize(config); + + try { + return await mermaid.render(id, text); + } catch (e) { + // Because Mermaid add a weird SVG/Message to the DOM on error + // https://github.com/mermaid-js/mermaid/issues/3205#issuecomment-1719620183 + if (typeof document !== 'undefined') { + document.querySelector(`#d${id}`)?.remove(); + } + throw e; + } + }; + + // Multiple diagrams on a page mount concurrently. Mermaid is a mutable + // singleton, so concurrent initialize() + render() calls can corrupt + // internal state and produce empty SVGs. We serialize renders in a queue. + const result = renderQueue.then(run, run); + renderQueue = result.catch(() => {}); + return result; } export function useMermaidRenderResult({ @@ -92,20 +109,32 @@ export function useMermaidRenderResult({ const config = providedConfig ?? defaultMermaidConfig; useEffect(() => { + let isCancelled = false; + // Mermaid removes existing DOM elements with the requested ID. Reusing an // ID can delete the displayed SVG, which React won't restore if the new // SVG markup is identical. Give every render its own ID instead. renderMermaid({id: `${id}-${renderCount.current++}`, text, config}) // TODO maybe try to use Suspense here and throw the promise? // See also https://github.com/pmndrs/suspend-react - .then(setResult) + .then((res) => { + if (!isCancelled) { + setResult(res); + } + }) .catch((e) => { - // Funky way to trigger parent React error boundary - // See https://x.com/sebastienlorber/status/1628340871899893768 - setResult(() => { - throw e; - }); + if (!isCancelled) { + // Funky way to trigger parent React error boundary + // See https://x.com/sebastienlorber/status/1628340871899893768 + setResult(() => { + throw e; + }); + } }); + + return () => { + isCancelled = true; + }; }, [id, text, config]); return result;