Skip to content

fix(mermaid): fix streaming render jitter by stabilizing id and using debounce - #1975

Open
Somtry wants to merge 1 commit into
ant-design:mainfrom
Somtry:fix/mermaid-streaming-jitter
Open

fix(mermaid): fix streaming render jitter by stabilizing id and using debounce#1975
Somtry wants to merge 1 commit into
ant-design:mainfrom
Somtry:fix/mermaid-streaming-jitter

Conversation

@Somtry

@Somtry Somtry commented Jul 16, 2026

Copy link
Copy Markdown

Related Issue

Fixes #1947

Problem

Mermaid 组件流式渲染时,id 每次渲染都变化(uuid++ + children.length),导致 mermaid 创建的 DOM 节点反复 mount/unmount,引起页面抖动。

Changes

  1. id 稳定化:用 React useId() 替代模块级 uuid++,确保同一组件实例在多次渲染中 id 不变
  2. 防抖渲染:throttle(100ms) → debounce(200ms),流式输出时等内容稳定后再渲染
  3. 语法不完整时静默跳过:流式过程中 mermaid 语法不完整时 return 而非 throw,避免刷错误日志
  4. 卸载安全:渲染前检查 containerRef.current 是否存在;useEffect 清理时 cancel debounce

Test

  • ✅ 所有 75 个已有测试通过
  • ✅ 新增 5 个回归测试覆盖 id 稳定性、流式防抖、多实例隔离等场景

Summary by CodeRabbit

  • Bug Fixes
    • 优化 Mermaid 图表的流式渲染,快速更新后仅执行最终一次渲染。
    • 修复图表渲染过程中的稳定性问题,避免组件卸载后出现多余操作或错误。
    • 不完整的 Mermaid 语法将暂不渲染,内容恢复完整后自动继续渲染。
    • 提升多图表实例的标识稳定性,确保图表正常更新与显示。

… debounce

- Replace module-level  with React  for stable render id
- Replace  with  to avoid repeated renders during streaming
- Skip rendering silently when mermaid syntax is incomplete (streaming)
- Add cleanup to cancel pending debounced render on unmount
- Add regression tests for id stability and streaming behavior

Fixes ant-design#1947
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Mermaid 组件改用 React useId 生成稳定图表 id,并以防抖方式处理异步渲染;不完整语法会静默跳过,卸载时取消待执行任务。测试覆盖防抖、流式更新、id 稳定性和生命周期清理。

Changes

Mermaid 渲染稳定性

Layer / File(s) Summary
稳定 id 与防抖渲染
packages/x/components/mermaid/Mermaid.tsx
使用 useId 生成稳定 id,改用 200ms 防抖渲染;不完整解析直接跳过,并在容器存在时写入结果,清理时取消待执行任务。
防抖与流式渲染回归验证
packages/x/components/mermaid/__tests__/index.test.tsx
验证快速更新后的单次渲染、跨重渲染 id 稳定、不同实例 id 不同、不完整语法跳过、恢复后渲染及卸载清理。

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MermaidComponent
  participant MermaidParser
  participant DOMContainer
  MermaidComponent->>MermaidParser: 防抖后解析并渲染图表
  MermaidParser-->>MermaidComponent: 返回 SVG 或解析失败
  MermaidComponent->>DOMContainer: 容器存在时写入 SVG
  MermaidComponent->>MermaidComponent: 依赖变化或卸载时取消防抖任务
Loading

Poem

小兔挥挥耳朵,防抖轻轻跳,
稳定 id 不乱跑,
流式图形慢慢好,
语法不全先静悄,
卸载任务及时消。

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了本次 Mermaid 流式渲染抖动修复、id 稳定化和防抖渲染的核心改动。
Linked Issues check ✅ Passed 代码与 #1947 的核心目标一致:稳定 id、改为防抖渲染,并补充了流式场景回归测试。
Out of Scope Changes check ✅ Passed 修改集中在 Mermaid 组件与其回归测试,未见与目标无关的额外改动。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves the stability of the Mermaid component during streaming renders (addressing issue #1947) by replacing the global uuid counter with React's useId hook to ensure stable IDs across re-renders. It also switches from throttle to debounce with a 200ms delay, silently skips rendering on incomplete syntax, and adds a cleanup mechanism to cancel pending renders on unmount. The feedback highlights a critical issue where the debounced renderDiagram function is recreated on every render, defeating the purpose of debouncing and causing performance overhead. It is recommended to memoize this function using useMemo and useRef, and subsequently add it to the useEffect dependency array.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +105 to +120
const renderDiagram = debounce(async () => {
if (!children || !containerRef.current || renderType === RenderType.Code) return;

try {
const isValid = await mermaid.parse(children, { suppressErrors: true });
if (!isValid) throw new Error('Invalid Mermaid syntax');
if (!isValid) return; // 流式渲染过程中语法不完整时静默跳过,不报错

const { svg } = await mermaid.render(id, children);
containerRef.current.innerHTML = svg;
// 确保渲染完成后容器仍然存在(防止异步过程中组件已卸载)
if (containerRef.current) {
containerRef.current.innerHTML = svg;
}
} catch (error) {
warning(false, 'Mermaid', `Render failed: ${error}`);
}
}, 100);
}, 200);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在当前的实现中,renderDiagram 函数在每次组件渲染时都会被重新创建(即每次都会调用 debounce(...) 生成一个新的防抖函数实例)。

由于 Mermaid 组件在拖拽(isDraggingposition 改变)或缩放(scale 改变)时会频繁触发重新渲染,这会导致在拖拽/缩放过程中频繁、重复地创建新的 debounce 函数,造成不必要的内存开销和垃圾回收压力。

此外,由于每次渲染都是全新的 debounce 实例,防抖功能实际上完全依赖于 useEffect 的清理函数来手动调用 .cancel()。这不仅不符合 React 的声明式设计,也容易在复杂的生命周期中引入潜在的竞态问题。

建议:
使用 React.useMemo 配合 useRef 来缓存最新的 childrenrenderType,从而保持 renderDiagram 的引用在多次渲染之间保持稳定。

  const latestPropsRef = useRef({ children, renderType });
  latestPropsRef.current = { children, renderType };

  const renderDiagram = React.useMemo(() => {
    return debounce(async () => {
      const { children: currentChildren, renderType: currentRenderType } = latestPropsRef.current;
      if (!currentChildren || !containerRef.current || currentRenderType === RenderType.Code) return;

      try {
        const isValid = await mermaid.parse(currentChildren, { suppressErrors: true });
        if (!isValid) return; // 流式渲染过程中语法不完整时静默跳过,不报错

        const { svg } = await mermaid.render(id, currentChildren);
        // 确保渲染完成后容器仍然存在(防止异步过程中组件已卸载)
        if (containerRef.current) {
          containerRef.current.innerHTML = svg;
        }
      } catch (error) {
        warning(false, 'Mermaid', 'Render failed: ' + error);
      }
    }, 200);
  }, [id]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Somtry cc

return () => {
renderDiagram.cancel();
};
}, [children, renderType, config]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了遵循 React Hooks 的依赖规范(react-hooks/exhaustive-deps),建议将稳定后的 renderDiagram 函数也加入到 useEffect 的依赖数组中。

Suggested change
}, [children, renderType, config]);
}, [children, renderType, config, renderDiagram]);

@Somtry

Somtry commented Jul 16, 2026

Copy link
Copy Markdown
Author

#1947

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/x/components/mermaid/Mermaid.tsx (1)

105-131: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

renderDiagram 防抖函数在每次渲染时都会重新创建。

debounce(...) 调用直接写在组件体中,没有用 useMemo/useCallback 包裹,因此每次组件渲染(包括与 children/renderType/config 无关的渲染,例如拖拽缩放导致的 setPosition/setScale 更新)都会生成一个全新的防抖闭包。当前实现之所以“看起来”能正确防抖,是因为依赖 useEffect 的清理时机——依赖变化时,React 会先调用上一次渲染闭包捕获的 renderDiagram.cancel() 再调度新的一次,这是一种隐式、脆弱的正确性保证,而非显式设计。副作用:

  • 违反 react-hooks/exhaustive-deps(effect 内使用了 renderDiagram 却未加入依赖数组)。
  • 拖拽/滚轮缩放等高频重渲染场景下,每次渲染都创建被丢弃的 debounce 包装对象,造成不必要开销。

建议用 useMemo 固定防抖函数,并让依赖数组显式声明。

♻️ 建议的重构
-import React, { useEffect, useId, useRef, useState } from 'react';
+import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
-  const renderDiagram = debounce(async () => {
-    if (!children || !containerRef.current || renderType === RenderType.Code) return;
-
-    try {
-      const isValid = await mermaid.parse(children, { suppressErrors: true });
-      if (!isValid) return; // 流式渲染过程中语法不完整时静默跳过,不报错
-
-      const { svg } = await mermaid.render(id, children);
-      // 确保渲染完成后容器仍然存在(防止异步过程中组件已卸载)
-      if (containerRef.current) {
-        containerRef.current.innerHTML = svg;
-      }
-    } catch (error) {
-      warning(false, 'Mermaid', `Render failed: ${error}`);
-    }
-  }, 200);
+  const renderDiagram = useMemo(
+    () =>
+      debounce(async () => {
+        if (!children || !containerRef.current || renderType === RenderType.Code) return;
+
+        try {
+          const isValid = await mermaid.parse(children, { suppressErrors: true });
+          if (!isValid) return; // 流式渲染过程中语法不完整时静默跳过,不报错
+
+          const { svg } = await mermaid.render(id, children);
+          if (containerRef.current) {
+            containerRef.current.innerHTML = svg;
+          }
+        } catch (error) {
+          warning(false, 'Mermaid', `Render failed: ${error}`);
+        }
+      }, 200),
+    [children, renderType, id],
+  );
   useEffect(() => {
     if (renderType === RenderType.Code && containerRef.current) {
       containerRef.current.innerHTML = '';
     } else {
       renderDiagram();
     }
     return () => {
       renderDiagram.cancel();
     };
-  }, [children, renderType, config]);
+  }, [renderDiagram, renderType]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/x/components/mermaid/Mermaid.tsx` around lines 105 - 131, 将 Mermaid
组件中的 renderDiagram 防抖函数改为通过 useMemo 稳定创建,避免每次组件渲染都生成新的 debounce 闭包;在 useMemo
依赖中声明其使用的 children、containerRef、renderType、id 及相关 Mermaid 配置,并将 renderDiagram 加入
useEffect 依赖数组,同时保留现有取消调度和卸载清理逻辑。
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/x/components/mermaid/__tests__/index.test.tsx`:
- Around line 1234-1257: Update the re-render loop in the “should not use uuid++
that changes every render” test to wait for mockRender to receive a new call
after each rerender, rather than merely asserting it has ever been called. Track
the call count before rerender and require it to increase before reading the
latest call, so the test verifies every debounced streaming render occurred and
retained the same id.

---

Nitpick comments:
In `@packages/x/components/mermaid/Mermaid.tsx`:
- Around line 105-131: 将 Mermaid 组件中的 renderDiagram 防抖函数改为通过 useMemo
稳定创建,避免每次组件渲染都生成新的 debounce 闭包;在 useMemo 依赖中声明其使用的
children、containerRef、renderType、id 及相关 Mermaid 配置,并将 renderDiagram 加入 useEffect
依赖数组,同时保留现有取消调度和卸载清理逻辑。
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 95172f84-bce6-4445-9976-5fb4df27890e

📥 Commits

Reviewing files that changed from the base of the PR and between 17db20b and 9a58bbc.

📒 Files selected for processing (2)
  • packages/x/components/mermaid/Mermaid.tsx
  • packages/x/components/mermaid/__tests__/index.test.tsx

Comment on lines +1234 to +1257
it('should not use uuid++ that changes every render', async () => {
const { rerender } = render(<Mermaid>{mermaidContent}</Mermaid>);

await waitFor(() => {
expect(mockRender).toHaveBeenCalled();
});

// Record id from first render
const ids: string[] = [mockRender.mock.calls[0][0]];

// Multiple re-renders simulating streaming
for (let i = 0; i < 5; i++) {
rerender(<Mermaid>{`${mermaidContent} ${i}`}</Mermaid>);
await waitFor(() => {
expect(mockRender).toHaveBeenCalled();
});
const lastCall = mockRender.mock.calls[mockRender.mock.calls.length - 1];
ids.push(lastCall[0]);
}

// All ids should be identical
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(1);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

循环内 toHaveBeenCalled() 断言无法确认新渲染真正发生。

mockRender 在循环开始前已被调用过一次,expect(mockRender).toHaveBeenCalled() 在后续每次迭代中都会立即为真(无需等待本轮 200ms 防抖真正触发),因此 waitFor 可能在新渲染尚未发生前就返回,lastCall 可能重复取到之前的调用。即便实现存在回归(例如后续渲染完全没有触发),该测试仍可能因为 uniqueIds.size === 1 恒成立而“通过”,无法真正验证多次流式渲染都保持相同 id。

💚 建议的修复
       for (let i = 0; i < 5; i++) {
         rerender(<Mermaid>{`${mermaidContent} ${i}`}</Mermaid>);
         await waitFor(() => {
-          expect(mockRender).toHaveBeenCalled();
+          expect(mockRender).toHaveBeenCalledTimes(i + 2);
         });
         const lastCall = mockRender.mock.calls[mockRender.mock.calls.length - 1];
         ids.push(lastCall[0]);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should not use uuid++ that changes every render', async () => {
const { rerender } = render(<Mermaid>{mermaidContent}</Mermaid>);
await waitFor(() => {
expect(mockRender).toHaveBeenCalled();
});
// Record id from first render
const ids: string[] = [mockRender.mock.calls[0][0]];
// Multiple re-renders simulating streaming
for (let i = 0; i < 5; i++) {
rerender(<Mermaid>{`${mermaidContent} ${i}`}</Mermaid>);
await waitFor(() => {
expect(mockRender).toHaveBeenCalled();
});
const lastCall = mockRender.mock.calls[mockRender.mock.calls.length - 1];
ids.push(lastCall[0]);
}
// All ids should be identical
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(1);
});
it('should not use uuid++ that changes every render', async () => {
const { rerender } = render(<Mermaid>{mermaidContent}</Mermaid>);
await waitFor(() => {
expect(mockRender).toHaveBeenCalled();
});
// Record id from first render
const ids: string[] = [mockRender.mock.calls[0][0]];
// Multiple re-renders simulating streaming
for (let i = 0; i < 5; i++) {
rerender(<Mermaid>{`${mermaidContent} ${i}`}</Mermaid>);
await waitFor(() => {
expect(mockRender).toHaveBeenCalledTimes(i + 2);
});
const lastCall = mockRender.mock.calls[mockRender.mock.calls.length - 1];
ids.push(lastCall[0]);
}
// All ids should be identical
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(1);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/x/components/mermaid/__tests__/index.test.tsx` around lines 1234 -
1257, Update the re-render loop in the “should not use uuid++ that changes every
render” test to wait for mockRender to receive a new call after each rerender,
rather than merely asserting it has ever been called. Track the call count
before rerender and require it to increase before reading the latest call, so
the test verifies every debounced streaming render occurred and retained the
same id.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OSS26-Team] 修复 Mermaid 流式渲染抖动(id 重复创建销毁)

2 participants