fix(mermaid): 主题切换时重渲染已渲染的流程图

mermaid 把颜色烤进 SVG 内联样式,无法靠 CSS 原地切主题。此前 initMermaid
的幂等守卫一刀切跳过所有已渲染块,导致切主题后流程图停留在首次渲染的主题。

改动:
- initMermaid 改为双路径分发:未渲染块(<code>还在)走 IntersectionObserver
  懒加载;已渲染块(pre 内容已是 SVG)按 dataset.mermaidSource 缓存源码重渲染,
  同主题幂等跳过,主题变化才重渲染。
- 渲染逻辑抽到 renderBlock:用全局自增 renderCounter 生成唯一 id,避开 mermaid
  残留的 d-前缀布局节点冲突(#357)与同页 marker id 冲突(#5741);替换前清空
  pre.innerHTML 确保亮/暗两版 SVG 不并存。
- source 存进 dataset.mermaidSource 供重渲染回取(此时 <code> 已被 SVG 替换)。

触发机制不变:post_content.rs 的 use_effect 读 use_resolved_theme() 建订阅,
主题切换时重跑重调 __initMermaid 传入新 theme;mermaid.ts 据此决定是否重渲染。
This commit is contained in:
xfy 2026-07-16 16:42:04 +08:00
parent f7a3336b76
commit 6eb7a743e5
3 changed files with 137 additions and 17 deletions

View File

@ -117,4 +117,67 @@ describe('initMermaid', () => {
expect(root.querySelector('pre')?.classList.contains('mermaid-error')).toBe(true);
});
});
it('主题切换时重渲染已渲染的块', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
// 首次渲染light
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('light');
const firstRenderCalls = mockRender.mock.calls.length;
// 主题切换 → dark应触发重渲染
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockRender.mock.calls.length).toBeGreaterThan(firstRenderCalls);
});
expect(mockInitialize).toHaveBeenLastCalledWith(expect.objectContaining({ theme: 'dark' }));
expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('dark');
});
it('主题未变时重渲染路径幂等(同主题跳过)', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
mockRender.mockClear();
// 同主题再调模拟上下篇切换复用组件实例、effect 重跑)
window.__initMermaid('.post-content', 'light');
await new Promise((r) => setTimeout(r, 50));
expect(mockRender).not.toHaveBeenCalled();
});
it('主题切换重渲染用唯一 render id避免 mermaid 残留节点冲突)', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
const firstId = mockRender.mock.calls[0][0];
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockRender.mock.calls.length).toBeGreaterThanOrEqual(2);
});
const secondId = mockRender.mock.calls[mockRender.mock.calls.length - 1][0];
// 两次 render 的 id 必须不同,否则撞上 mermaid 内部残留的 d-前缀节点(#357
expect(secondId).not.toBe(firstId);
});
});

View File

@ -12,6 +12,11 @@
* post-content.tsquerySelectorAll + + DOM
* mermaid SSR markdown.rs
* `language-mermaid` class data
*
* mermaid / SVG CSS
* SVG render SVGpost_content.rs
* use_effect use_resolved_theme() __initMermaid
* theme dataset.mermaidTheme
*/
import type { ThemeName } from '@yggdrasil/shared';
@ -29,6 +34,9 @@ declare global {
let mermaidPromise: Promise<MermaidApi> | null = null;
/** render id 自增计数器,保证每次 render 生成唯一 id避开 mermaid 残留节点冲突。 */
let renderCounter = 0;
/**
* mermaid IIFE bundle 便
*
@ -75,6 +83,33 @@ function loadMermaid(): Promise<MermaidApi> {
return mermaidPromise;
}
/**
* mermaid SVG <pre>
*
* - render id`mermaid-svg-${++renderCounter}` render
* mermaid `d`-mermaid#357 marker id
* mermaid#5741
* - pre.innerHTML SVG `d-` SVG
* /
* - source dataset.mermaidSource <code>
* SVG textContent
*/
async function renderBlock(pre: HTMLPreElement, source: string, theme: ThemeName): Promise<void> {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
});
const id = `mermaid-svg-${++renderCounter}`;
const { svg } = await mermaid.render(id, source);
// 清空 pre 旧内容(旧 SVG + mermaid 残留的 `d-` 辅助节点),再注入新 SVG。
pre.innerHTML = svg;
pre.dataset.mermaidRendered = 'true';
pre.dataset.mermaidSource = source;
pre.dataset.mermaidTheme = theme;
}
/**
* mermaid <pre> IntersectionObserver
*
@ -101,33 +136,32 @@ function observeBlock(pre: HTMLPreElement, render: () => Promise<void>): void {
/**
* mermaid
*
* <code> SVG pre
* 1. `<code class="language-mermaid">` pre +
* IntersectionObserver
* 2. pre SVGdataset.mermaid-rendered
* post_content.rs effect
* bundle IntersectionObserver
*
* @param selector '.post-content'
* @param theme mermaid
*/
export function initMermaid(selector: string, theme: ThemeName): void {
const root = document.querySelector(selector);
if (!root) return;
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
if (blocks.length === 0) return;
blocks.forEach((code, i) => {
// 路径 1未渲染的块<code> 还在)。
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
blocks.forEach((code) => {
const pre = code.parentElement as HTMLPreElement | null;
if (!pre) return;
if (pre.dataset.mermaidRendered) return; // 幂等:上下篇切换重复调用不重渲染
if (pre.dataset.mermaidRendered) return; // 理论不命中renderBlock 同步替换 innerHTML防御
const source = code.textContent || '';
pre.dataset.mermaidSource = source;
observeBlock(pre, async () => {
try {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
});
const { svg } = await mermaid.render(`mermaid-svg-${i}`, source);
// 替换整个 <pre> 内容为 SVG丢弃 copy 按钮(图不需要复制源码)。
pre.innerHTML = svg;
pre.dataset.mermaidRendered = 'true';
await renderBlock(pre, source, theme);
} catch (err) {
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
@ -136,4 +170,27 @@ export function initMermaid(selector: string, theme: ThemeName): void {
}
});
});
// 路径 2已渲染的块<code> 已被 SVG 替换,按 dataset 回找)。无条件执行,
// 覆盖「页面上未渲染块与已渲染块并存」的场景。
rerenderExistingBlocks(root, theme);
}
/**
* pre SVG <code>
*
* initMermaid pre <code> SVG
* `pre > code.language-mermaid` dataset
*/
function rerenderExistingBlocks(root: Element, theme: ThemeName): void {
const rendered = root.querySelectorAll<HTMLPreElement>('pre[data-mermaid-rendered]');
rendered.forEach((pre) => {
if (pre.dataset.mermaidTheme === theme) return;
const source = pre.dataset.mermaidSource;
if (!source) return; // 无缓存源码无法重渲染,保守跳过
void renderBlock(pre, source, theme).catch((err) => {
console.error('mermaid re-render failed:', err);
pre.classList.add('mermaid-error');
});
});
}

View File

@ -137,9 +137,9 @@ pub fn PostContent(content_html: String) -> Element {
let fragments = split_content_fragments(&content_html);
// mermaid 流程图主题需随当前生效主题light/dark切换。读 use_resolved_theme()
// 建立订阅:主题变化时下方 use_effect 重跑,重新调用 __initMermaid幂等
// 已渲染的块由 dataset.mermaidRendered 守卫跳过;主题切换暂不强制重渲染图,
// 后续如需可在 mermaid.ts 监听 THEME_CHANGE_EVENT)。
// 建立订阅:主题变化时下方 use_effect 重跑,重调 __initMermaid 传入新 theme
// mermaid.ts 用 dataset.mermaidTheme 记住上次渲染主题,检测到主题变化时按缓存
// 源码重渲染mermaid 颜色烤进 SVG 内联样式,无法靠 CSS 原地切主题)。
#[cfg(target_arch = "wasm32")]
let resolved_theme = crate::theme::use_resolved_theme();