From fc4c96b663ad4985562245dd5a4538174f555360 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Mon, 7 Sep 2026 18:19:22 +0800 Subject: [PATCH 1/2] perf(ui): cache Mermaid renders across remounts Reuse sanitized Mermaid SVG templates by renderer version, theme, and source while bounding cache memory and preserving instance-local SVG references. Coalesce identical in-flight renders and cover remounts, theme changes, cancellation, ID isolation, and LRU eviction. Fixes #4976 Generated-by: Codex --- .../__tests__/mermaid-render-cache.test.tsx | 349 ++++++++++++++++++ packages/ui/src/mermaid-diagram.tsx | 211 ++++++++++- 2 files changed, 547 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/__tests__/mermaid-render-cache.test.tsx diff --git a/packages/ui/src/__tests__/mermaid-render-cache.test.tsx b/packages/ui/src/__tests__/mermaid-render-cache.test.tsx new file mode 100644 index 0000000000..60c50202c0 --- /dev/null +++ b/packages/ui/src/__tests__/mermaid-render-cache.test.tsx @@ -0,0 +1,349 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { LocaleProvider } from '../locale-context.js'; +import { + MERMAID_RENDER_CACHE_LIMIT, + MermaidDiagram, +} from '../mermaid-diagram.js'; + +const GLOBAL_KEYS = [ + 'CSS', + 'DOMParser', + 'Element', + 'HTMLElement', + 'IS_REACT_ACT_ENVIRONMENT', + 'MutationObserver', + 'Node', + 'ResizeObserver', + 'XMLSerializer', + 'cancelAnimationFrame', + 'document', + 'getComputedStyle', + 'navigator', + 'requestAnimationFrame', + 'window', +] as const; + +function installDom() { + const originals = new Map( + GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + const { document, window } = parseHTML('
'); + Object.defineProperty(window, 'location', { + configurable: true, + value: new URL('http://localhost/'), + }); + + class InertResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + class TestXmlSerializer { + serializeToString(node: Node): string { + return String(node); + } + } + const globals = { + CSS: { escape: String, supports: () => false }, + DOMParser: window.DOMParser, + Element: window.Element, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: window.MutationObserver, + Node: window.Node, + ResizeObserver: InertResizeObserver, + XMLSerializer: TestXmlSerializer, + cancelAnimationFrame: () => {}, + document, + getComputedStyle: () => ({ + paddingBottom: '0', + paddingLeft: '0', + paddingRight: '0', + paddingTop: '0', + }), + navigator: window.navigator ?? { userAgent: 'node' }, + requestAnimationFrame: (callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)); + return 1; + }, + window, + }; + for (const [key, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); + } + Object.assign(window, { + CSS: globals.CSS, + cancelAnimationFrame: globals.cancelAnimationFrame, + getComputedStyle: globals.getComputedStyle, + innerHeight: 800, + requestAnimationFrame: globals.requestAnimationFrame, + }); + + return { + document, + restore() { + for (const key of GLOBAL_KEYS) { + const descriptor = originals.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }, + }; +} + +async function settleEffects(): Promise { + for (let index = 0; index < 8; index += 1) { + await act(async () => Promise.resolve()); + } +} + +function diagram(code: string) { + return ( + + + + ); +} + +test('reuses a rendered Mermaid result across remounts and isolates themes', async () => { + const dom = installDom(); + const mermaid = (await import('mermaid')).default; + const originalInitialize = mermaid.initialize; + const originalRender = mermaid.render; + let renderCalls = 0; + const themes: string[] = []; + mermaid.initialize = (config) => { + themes.push(String(config.theme)); + }; + mermaid.render = async (id, code) => { + renderCalls += 1; + return { + diagramType: 'flowchart-v2', + svg: `${code}`, + }; + }; + + const code = 'flowchart LR\ncache_a --> cache_b'; + const view = () => diagram(code); + let root = createRoot(dom.document.querySelector('#root')!); + + try { + await act(async () => root.render(view())); + await settleEffects(); + assert.equal(renderCalls, 1); + + await act(async () => root.unmount()); + const remount = dom.document.createElement('div'); + dom.document.body.appendChild(remount); + root = createRoot(remount); + await act(async () => root.render(view())); + await settleEffects(); + assert.equal(renderCalls, 1, 'same source and theme should hit the render cache'); + + dom.document.documentElement.classList.add('dark'); + await settleEffects(); + assert.equal(renderCalls, 2, 'dark theme must use a distinct render result'); + + dom.document.documentElement.classList.remove('dark'); + await settleEffects(); + assert.equal(renderCalls, 2, 'switching back should reuse the cached default-theme result'); + assert.deepEqual(themes, ['default', 'dark']); + } finally { + await act(async () => root.unmount()); + mermaid.initialize = originalInitialize; + mermaid.render = originalRender; + dom.restore(); + } +}); + +test('coalesces concurrent Mermaid renders and gives cached instances unique SVG ids', async () => { + const dom = installDom(); + const mermaid = (await import('mermaid')).default; + const originalInitialize = mermaid.initialize; + const originalRender = mermaid.render; + let renderCalls = 0; + let releaseRender: (() => void) | undefined; + mermaid.initialize = () => {}; + mermaid.render = (id) => { + renderCalls += 1; + return new Promise((resolve) => { + releaseRender = () => resolve({ + diagramType: 'flowchart-v2', + svg: [ + ``, + ``, + 'Title', + ``, + ``, + '', + ].join(''), + }); + }); + }; + + const code = 'flowchart LR\nconcurrent_a --> concurrent_b'; + const root = createRoot(dom.document.querySelector('#root')!); + + try { + await act(async () => root.render( + + + + , + )); + await settleEffects(); + assert.equal(renderCalls, 1, 'same-key mounts should share the in-flight render'); + + await act(async () => releaseRender?.()); + await settleEffects(); + const diagrams = Array.from(dom.document.querySelectorAll('.maka-mermaid-svg > svg')); + assert.equal(diagrams.length, 2); + const firstIds = new Set(Array.from(diagrams[0]!.querySelectorAll('[id]'), (node) => node.id)); + const secondIds = new Set(Array.from(diagrams[1]!.querySelectorAll('[id]'), (node) => node.id)); + assert.equal([...firstIds].some((id) => secondIds.has(id)), false, 'instances must not share DOM ids'); + for (const svg of diagrams) { + const marker = svg.querySelector('marker')!; + const filter = svg.querySelector('filter')!; + const group = svg.querySelector('g')!; + const path = svg.querySelector('path[id]')!; + const title = svg.querySelector('title')!; + assert.equal(path.getAttribute('marker-end'), `url(#${marker.id})`); + assert.equal(group.getAttribute('filter'), `url(#${filter.id})`); + assert.equal(svg.getAttribute('aria-labelledby'), title.id); + const style = svg.querySelector('style')!.textContent ?? ''; + assert.match(style, new RegExp(`#${path.id}`)); + assert.match(style, new RegExp(`#${group.id}`)); + assert.match(style, new RegExp(`url\\(#${filter.id}\\)`)); + } + } finally { + await act(async () => root.unmount()); + mermaid.initialize = originalInitialize; + mermaid.render = originalRender; + dom.restore(); + } +}); + +test('does not cancel a shared Mermaid render while another consumer remains mounted', async () => { + const dom = installDom(); + const mermaid = (await import('mermaid')).default; + const originalInitialize = mermaid.initialize; + const originalRender = mermaid.render; + let renderCalls = 0; + let releaseRender: (() => void) | undefined; + mermaid.initialize = () => {}; + mermaid.render = (id, code) => { + renderCalls += 1; + return new Promise((resolve) => { + releaseRender = () => resolve({ + diagramType: 'flowchart-v2', + svg: `${code}`, + }); + }); + }; + + const code = 'flowchart LR\nremaining_a --> remaining_b'; + const root = createRoot(dom.document.querySelector('#root')!); + const view = (showFirst: boolean) => ( + + {showFirst ? : null} + + + ); + + try { + await act(async () => root.render(view(true))); + await settleEffects(); + assert.equal(renderCalls, 1); + + await act(async () => root.render(view(false))); + await act(async () => releaseRender?.()); + await settleEffects(); + assert.equal( + dom.document.querySelectorAll('[data-maka-mermaid-state="rendered"]').length, + 1, + ); + } finally { + await act(async () => root.unmount()); + mermaid.initialize = originalInitialize; + mermaid.render = originalRender; + dom.restore(); + } +}); + +test('bounds the Mermaid cache and evicts its least recently used result', async () => { + const dom = installDom(); + const mermaid = (await import('mermaid')).default; + const originalInitialize = mermaid.initialize; + const originalRender = mermaid.render; + let renderCalls = 0; + mermaid.initialize = () => {}; + mermaid.render = async (id, code) => { + renderCalls += 1; + return { + diagramType: 'flowchart-v2', + svg: `${code}`, + }; + }; + + const root = createRoot(dom.document.querySelector('#root')!); + const prefix = `lru-${Date.now()}-`; + const renderCode = async (code: string) => { + await act(async () => root.render(diagram(code))); + await settleEffects(); + }; + + try { + for (let index = 0; index < MERMAID_RENDER_CACHE_LIMIT; index += 1) { + await renderCode(`${prefix}${index}`); + } + assert.equal(renderCalls, MERMAID_RENDER_CACHE_LIMIT); + + await renderCode(`${prefix}0`); + assert.equal(renderCalls, MERMAID_RENDER_CACHE_LIMIT, 'a cache hit should refresh recency'); + + await renderCode(`${prefix}${MERMAID_RENDER_CACHE_LIMIT}`); + assert.equal(renderCalls, MERMAID_RENDER_CACHE_LIMIT + 1); + + await renderCode(`${prefix}0`); + assert.equal(renderCalls, MERMAID_RENDER_CACHE_LIMIT + 1, 'the refreshed entry should survive'); + + await renderCode(`${prefix}1`); + assert.equal( + renderCalls, + MERMAID_RENDER_CACHE_LIMIT + 2, + 'the oldest entry should be rendered again after LRU eviction', + ); + } finally { + await act(async () => root.unmount()); + mermaid.initialize = originalInitialize; + mermaid.render = originalRender; + dom.restore(); + } +}); diff --git a/packages/ui/src/mermaid-diagram.tsx b/packages/ui/src/mermaid-diagram.tsx index 7fd5e49397..0151bb7db2 100644 --- a/packages/ui/src/mermaid-diagram.tsx +++ b/packages/ui/src/mermaid-diagram.tsx @@ -24,18 +24,34 @@ import { Dialog } from '@astryxdesign/core/Dialog'; import { Toolbar } from '@astryxdesign/core/Toolbar'; import { useEffect, useRef, useState } from 'react'; import type { MermaidConfig } from 'mermaid'; +import mermaidPackage from 'mermaid/package.json' with { type: 'json' }; import { ICON_SIZE, Maximize2, Minimize2, Scan, ZoomIn, ZoomOut } from './icons.js'; import { useUiLocale } from './locale-context.js'; import { getSharedUiCopy } from './shared-ui-copy.js'; export const MAX_MERMAID_SOURCE_LENGTH = 20_000; export const MAX_MERMAID_EDGES = 500; +export const MERMAID_RENDER_CACHE_LIMIT = 24; +export const MERMAID_RENDER_CACHE_MAX_CHARS = 4 * 1024 * 1024; export const MIN_MERMAID_ZOOM = 0.5; export const MAX_MERMAID_ZOOM = 3; export const MERMAID_ZOOM_STEP = 0.25; const MIN_MERMAID_VIEWPORT_HEIGHT = 112; const MAX_MERMAID_VIEWPORT_HEIGHT = 480; const MAX_MERMAID_VIEWPORT_HEIGHT_RATIO = 0.55; +const MERMAID_RENDER_CACHE_SCHEMA_VERSION = 1; +const MERMAID_ID_REFERENCE_ATTRIBUTES = new Set([ + 'aria-activedescendant', + 'aria-controls', + 'aria-describedby', + 'aria-details', + 'aria-errormessage', + 'aria-flowto', + 'aria-labelledby', + 'aria-owns', + 'for', + 'headers', +]); type MermaidTheme = 'default' | 'dark'; @@ -50,9 +66,25 @@ type MermaidViewportLayout = { viewportHeight: number; }; +type MermaidRenderTemplate = { + svg: string; + namespace: string; + naturalWidth: number; + naturalHeight: number; +}; + +type MermaidRenderInFlight = { + promise: Promise; + consumers: Set<() => boolean>; +}; + let mermaidModule: Promise | undefined; let renderQueue: Promise = Promise.resolve(); let diagramSequence = 0; +const MERMAID_RENDERER_VERSION = `${mermaidPackage.version}:${MERMAID_RENDER_CACHE_SCHEMA_VERSION}`; +const mermaidRenderCache = new Map(); +const mermaidRenderInFlight = new Map(); +let mermaidRenderCacheChars = 0; export function createMermaidConfig(theme: MermaidTheme): MermaidConfig { return { @@ -72,7 +104,82 @@ function loadMermaid() { return mermaidModule; } -function sanitizeRenderedMermaidSvg(svg: string): string { +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function replaceMermaidLocalUrlReferences( + value: string, + replacements: ReadonlyMap, +): string { + return value.replace( + /url\(\s*(['"]?)#([^\s)'"}]+)\1\s*\)/g, + (match, quote: string, id: string) => { + const replacement = replacements.get(id); + return replacement ? `url(${quote}#${replacement}${quote})` : match; + }, + ); +} + +function replaceMermaidStyleIdReferences( + value: string, + replacements: ReadonlyMap, +): string { + let rewritten = replaceMermaidLocalUrlReferences(value, replacements); + for (const [id, replacement] of replacements) { + const selector = new RegExp( + `(^|[\\s,>+~}(.])#${escapeRegExp(id)}(?=$|[\\s,.:>+~{\\[])`, + 'gm', + ); + rewritten = rewritten.replace(selector, `$1#${replacement}`); + } + return rewritten; +} + +function namespaceRenderedMermaidIds(documentNode: Document, namespace: string): void { + const replacements = new Map(); + for (const element of documentNode.querySelectorAll('[id]')) { + const id = element.getAttribute('id'); + if (!id || id.includes(namespace)) continue; + let replacement = replacements.get(id); + if (!replacement) { + replacement = `${namespace}-scoped-${replacements.size}`; + replacements.set(id, replacement); + } + element.setAttribute('id', replacement); + } + if (replacements.size === 0) return; + + for (const element of documentNode.querySelectorAll('*')) { + for (const attribute of Array.from(element.attributes)) { + if (attribute.name.toLowerCase() === 'id') continue; + const name = attribute.name.toLowerCase(); + let value = replaceMermaidLocalUrlReferences(attribute.value, replacements); + if ((name === 'href' || name.endsWith(':href')) && value.startsWith('#')) { + const replacement = replacements.get(value.slice(1)); + if (replacement) value = `#${replacement}`; + } else if (MERMAID_ID_REFERENCE_ATTRIBUTES.has(name)) { + value = value + .split(/(\s+)/) + .map((token) => replacements.get(token) ?? token) + .join(''); + } else if (name === 'begin' || name === 'end') { + for (const [id, replacement] of replacements) { + value = value.replace( + new RegExp(`(^|;\\s*)${escapeRegExp(id)}(?=\\.)`, 'g'), + `$1${replacement}`, + ); + } + } + if (value !== attribute.value) element.setAttribute(attribute.name, value); + } + } + for (const style of documentNode.querySelectorAll('style')) { + style.textContent = replaceMermaidStyleIdReferences(style.textContent ?? '', replacements); + } +} + +function sanitizeRenderedMermaidSvg(svg: string, namespace: string): string { const documentNode = new DOMParser().parseFromString(svg, 'image/svg+xml'); if (documentNode.querySelector('parsererror')) throw new Error('Invalid Mermaid SVG output'); @@ -94,10 +201,56 @@ function sanitizeRenderedMermaidSvg(svg: string): string { } } } + namespaceRenderedMermaidIds(documentNode, namespace); return new XMLSerializer().serializeToString(documentNode.documentElement); } +function mermaidRenderCacheKey(code: string, theme: MermaidTheme): string { + return `${MERMAID_RENDERER_VERSION}\0${theme}\0${code}`; +} + +function touchMermaidRenderCacheEntry(key: string, entry: MermaidRenderTemplate): void { + mermaidRenderCache.delete(key); + mermaidRenderCache.set(key, entry); +} + +function trimMermaidRenderCache(): void { + while ( + mermaidRenderCache.size > MERMAID_RENDER_CACHE_LIMIT + || mermaidRenderCacheChars > MERMAID_RENDER_CACHE_MAX_CHARS + ) { + const oldestKey = mermaidRenderCache.keys().next().value; + if (oldestKey === undefined) break; + mermaidRenderCacheChars -= mermaidRenderCache.get(oldestKey)?.svg.length ?? 0; + mermaidRenderCache.delete(oldestKey); + } +} + +function writeMermaidRenderCache(key: string, template: MermaidRenderTemplate): void { + if (template.svg.length > MERMAID_RENDER_CACHE_MAX_CHARS) return; + const existing = mermaidRenderCache.get(key); + if (existing) mermaidRenderCacheChars -= existing.svg.length; + mermaidRenderCache.delete(key); + mermaidRenderCache.set(key, template); + mermaidRenderCacheChars += template.svg.length; + trimMermaidRenderCache(); +} + +function nextMermaidRenderId(kind: 'template' | 'instance', code = ''): string { + let id: string; + do id = `maka-mermaid-${kind}-${++diagramSequence}`; + while (code.includes(id)); + return id; +} + +function instantiateMermaidSvg(template: MermaidRenderTemplate): string { + return template.svg.replaceAll( + template.namespace, + nextMermaidRenderId('instance', template.svg), + ); +} + /** * Mermaid owns global configuration, so initialization and rendering must be * one serialized operation. This also caps concurrent layout work when one @@ -107,22 +260,50 @@ function renderMermaid( code: string, theme: MermaidTheme, shouldRender: () => boolean, -): Promise { - const task = renderQueue.then(async () => { - if (!shouldRender()) return null; +): Promise { + if (!shouldRender()) return Promise.resolve(null); + const cacheKey = mermaidRenderCacheKey(code, theme); + const cached = mermaidRenderCache.get(cacheKey); + if (cached) { + touchMermaidRenderCacheEntry(cacheKey, cached); + return Promise.resolve(shouldRender() ? cached : null); + } + + const inFlight = mermaidRenderInFlight.get(cacheKey); + if (inFlight) { + inFlight.consumers.add(shouldRender); + return inFlight.promise.then((template) => shouldRender() ? template : null); + } + + const consumers = new Set([shouldRender]); + const promise = renderQueue.then(async () => { + if (![...consumers].some((isActive) => isActive())) return null; const mermaid = await loadMermaid(); - if (!shouldRender()) return null; + if (![...consumers].some((isActive) => isActive())) return null; mermaid.initialize(createMermaidConfig(theme)); - const id = `maka-mermaid-${++diagramSequence}`; + const id = nextMermaidRenderId('template', code); const { svg } = await mermaid.render(id, code); - return sanitizeRenderedMermaidSvg(svg); + const sanitizedSvg = sanitizeRenderedMermaidSvg(svg, id); + const { width: naturalWidth, height: naturalHeight } = mermaidViewBoxSize(sanitizedSvg); + return { svg: sanitizedSvg, namespace: id, naturalWidth, naturalHeight }; + }); + const entry = { promise, consumers }; + mermaidRenderInFlight.set(cacheKey, entry); + void promise.then( + (template) => { + if (template) writeMermaidRenderCache(cacheKey, template); + }, + () => {}, + ).finally(() => { + if (mermaidRenderInFlight.get(cacheKey) === entry) mermaidRenderInFlight.delete(cacheKey); + consumers.clear(); }); - renderQueue = task.then( + renderQueue = promise.then( () => undefined, () => undefined, ); - return task; + return promise.then((template) => shouldRender() ? template : null); } function currentMermaidTheme(): MermaidTheme { @@ -217,10 +398,14 @@ export function MermaidDiagram(props: { setViewportLayout(null); setState({ status: 'loading' }); void renderMermaid(props.code, theme, () => !cancelled).then( - (svg) => { - if (!cancelled && svg) { - const { width: naturalWidth, height: naturalHeight } = mermaidViewBoxSize(svg); - setState({ status: 'rendered', svg, naturalWidth, naturalHeight }); + (template) => { + if (!cancelled && template) { + setState({ + status: 'rendered', + svg: instantiateMermaidSvg(template), + naturalWidth: template.naturalWidth, + naturalHeight: template.naturalHeight, + }); } }, () => { From abc1aadf011172980c7e253e9bffd50f5ea93c43 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Tue, 8 Sep 2026 13:22:28 +0800 Subject: [PATCH 2/2] fix(ui): preserve Mermaid attribute selectors Generated-by: Codex --- .../__tests__/mermaid-render-cache.test.tsx | 142 ++++++++++++++++++ packages/ui/src/mermaid-diagram.tsx | 9 ++ 2 files changed, 151 insertions(+) diff --git a/packages/ui/src/__tests__/mermaid-render-cache.test.tsx b/packages/ui/src/__tests__/mermaid-render-cache.test.tsx index 60c50202c0..becd30019c 100644 --- a/packages/ui/src/__tests__/mermaid-render-cache.test.tsx +++ b/packages/ui/src/__tests__/mermaid-render-cache.test.tsx @@ -30,6 +30,7 @@ import { const GLOBAL_KEYS = [ 'CSS', + 'CSSStyleSheet', 'DOMParser', 'Element', 'HTMLElement', @@ -37,6 +38,7 @@ const GLOBAL_KEYS = [ 'MutationObserver', 'Node', 'ResizeObserver', + 'SVGElement', 'XMLSerializer', 'cancelAnimationFrame', 'document', @@ -66,8 +68,24 @@ function installDom() { return String(node); } } + Object.defineProperties(window.SVGElement.prototype, { + getBBox: { + configurable: true, + value() { + return { x: 0, y: 0, width: Math.max(1, (this.textContent ?? '').length * 8), height: 16 }; + }, + }, + getComputedTextLength: { + configurable: true, + value() { + return Math.max(1, (this.textContent ?? '').length * 8); + }, + }, + }); + const CSSStyleSheet = document.createElement('style').sheet!.constructor; const globals = { CSS: { escape: String, supports: () => false }, + CSSStyleSheet, DOMParser: window.DOMParser, Element: window.Element, HTMLElement: window.HTMLElement, @@ -75,6 +93,7 @@ function installDom() { MutationObserver: window.MutationObserver, Node: window.Node, ResizeObserver: InertResizeObserver, + SVGElement: window.SVGElement, XMLSerializer: TestXmlSerializer, cancelAnimationFrame: () => {}, document, @@ -100,6 +119,7 @@ function installDom() { } Object.assign(window, { CSS: globals.CSS, + CSSStyleSheet, cancelAnimationFrame: globals.cancelAnimationFrame, getComputedStyle: globals.getComputedStyle, innerHeight: 800, @@ -132,6 +152,128 @@ function diagram(code: string) { ); } +test('preserves custom Mermaid attribute selectors after SVG ids are namespaced', async () => { + const dom = installDom(); + const mermaid = (await import('mermaid')).default; + const originalRender = mermaid.render; + let renderCalls = 0; + mermaid.render = (...args) => { + renderCalls += 1; + return originalRender(...args); + }; + const code = [ + '%%{init: {"themeCSS": "[id=\\"actor1\\"] { opacity: 0.25; }"}}%%', + 'sequenceDiagram', + 'Alice->>Bob: Hello', + ].join('\n'); + const actorId = () => { + const svg = dom.document.querySelector('.maka-mermaid-svg > svg'); + assert.ok(svg, 'real Mermaid output should render'); + const actor = svg.querySelector('line[data-et="life-line"][name="Bob"]'); + assert.ok(actor?.id, 'the sequence actor should retain an id'); + assert.notEqual(actor.id, 'actor1', 'the instance should namespace Mermaid ids'); + assert.ok( + Array.from(svg.querySelectorAll('style')).some((style) => + (style.textContent ?? '').includes(`[id="${actor.id}"]`)), + 'custom themeCSS should follow the namespaced actor id', + ); + return actor.id; + }; + let root = createRoot(dom.document.querySelector('#root')!); + + try { + await act(async () => root.render(diagram(code))); + await settleEffects(); + const firstActorId = actorId(); + + await act(async () => root.unmount()); + const remount = dom.document.createElement('div'); + dom.document.body.appendChild(remount); + root = createRoot(remount); + await act(async () => root.render(diagram(code))); + await settleEffects(); + assert.notEqual(actorId(), firstActorId, 'a cached remount should instantiate fresh ids'); + assert.equal(renderCalls, 1, 'the custom-theme template should come from the render cache'); + } finally { + await act(async () => root.unmount()); + mermaid.render = originalRender; + dom.restore(); + } +}); + +test('renders repeated real Mermaid diagrams across remounts and themes', async () => { + const dom = installDom(); + const mermaid = (await import('mermaid')).default; + const originalRender = mermaid.render; + let renderCalls = 0; + mermaid.render = (...args) => { + renderCalls += 1; + return originalRender(...args); + }; + const suffix = Date.now(); + const flowchartCode = `flowchart LR\nflow_${suffix}_a --> flow_${suffix}_b`; + const sequenceCode = `sequenceDiagram\nAlice->>Bob: Hello ${suffix}`; + const view = () => ( + + + + + + + ); + const renderedSvgs = () => + Array.from(dom.document.querySelectorAll('.maka-mermaid-svg > svg')); + const styleText = (svg: SVGSVGElement) => + Array.from(svg.querySelectorAll('style'), (style) => style.textContent ?? '').join('\n'); + const flowchartFill = (svg: SVGSVGElement) => { + const fill = /\.node rect[^{}]*\{[^{}]*\bfill:([^;]+);/.exec(styleText(svg))?.[1]; + assert.ok(fill, 'real Mermaid flowchart theme styles should render'); + return fill; + }; + const assertUniqueIds = (first: SVGSVGElement, second: SVGSVGElement) => { + const firstIds = new Set(Array.from(first.querySelectorAll('[id]'), (node) => node.id)); + assert.equal( + Array.from(second.querySelectorAll('[id]')).some((node) => firstIds.has(node.id)), + false, + 'repeated diagrams should not share DOM ids', + ); + }; + let root = createRoot(dom.document.querySelector('#root')!); + + try { + await act(async () => root.render(view())); + await settleEffects(); + const lightSvgs = renderedSvgs(); + assert.equal(lightSvgs.length, 4); + assert.equal(renderCalls, 2, 'identical mounts should coalesce real Mermaid renders'); + assertUniqueIds(lightSvgs[0]!, lightSvgs[1]!); + assertUniqueIds(lightSvgs[2]!, lightSvgs[3]!); + const lightFill = flowchartFill(lightSvgs[0]!); + + await act(async () => root.unmount()); + const remount = dom.document.createElement('div'); + dom.document.body.appendChild(remount); + root = createRoot(remount); + await act(async () => root.render(view())); + await settleEffects(); + assert.equal(renderCalls, 2, 'a real Mermaid remount should use both cached templates'); + + dom.document.documentElement.classList.add('dark'); + await settleEffects(); + const darkSvgs = renderedSvgs(); + assert.equal(darkSvgs.length, 4); + assert.equal(renderCalls, 4, 'dark mode should render one new template per diagram type'); + assert.notEqual(flowchartFill(darkSvgs[0]!), lightFill, 'dark should use Mermaid dark styles'); + assertUniqueIds(darkSvgs[0]!, darkSvgs[1]!); + assertUniqueIds(darkSvgs[2]!, darkSvgs[3]!); + } finally { + await act(async () => root.unmount()); + mermaid.render = originalRender; + dom.document.documentElement.classList.remove('dark'); + dom.restore(); + } +}); + test('reuses a rendered Mermaid result across remounts and isolates themes', async () => { const dom = installDom(); const mermaid = (await import('mermaid')).default; diff --git a/packages/ui/src/mermaid-diagram.tsx b/packages/ui/src/mermaid-diagram.tsx index 0151bb7db2..1a4678e8b6 100644 --- a/packages/ui/src/mermaid-diagram.tsx +++ b/packages/ui/src/mermaid-diagram.tsx @@ -127,6 +127,15 @@ function replaceMermaidStyleIdReferences( ): string { let rewritten = replaceMermaidLocalUrlReferences(value, replacements); for (const [id, replacement] of replacements) { + const attributeSelector = new RegExp( + `(\\[\\s*id\\s*=\\s*)(['"]?)${escapeRegExp(id)}\\2(\\s*(?:[iIsS]\\s*)?\\])`, + 'g', + ); + rewritten = rewritten.replace( + attributeSelector, + (_match, prefix: string, quote: string, suffix: string) => + `${prefix}${quote}${replacement}${quote}${suffix}`, + ); const selector = new RegExp( `(^|[\\s,>+~}(.])#${escapeRegExp(id)}(?=$|[\\s,.:>+~{\\[])`, 'gm',