fix(mermaid): fix streaming render jitter by stabilizing id and using debounce - #1975
fix(mermaid): fix streaming render jitter by stabilizing id and using debounce#1975Somtry wants to merge 1 commit into
Conversation
… 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
📝 WalkthroughWalkthroughMermaid 组件改用 React ChangesMermaid 渲染稳定性
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: 依赖变化或卸载时取消防抖任务
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
在当前的实现中,renderDiagram 函数在每次组件渲染时都会被重新创建(即每次都会调用 debounce(...) 生成一个新的防抖函数实例)。
由于 Mermaid 组件在拖拽(isDragging、position 改变)或缩放(scale 改变)时会频繁触发重新渲染,这会导致在拖拽/缩放过程中频繁、重复地创建新的 debounce 函数,造成不必要的内存开销和垃圾回收压力。
此外,由于每次渲染都是全新的 debounce 实例,防抖功能实际上完全依赖于 useEffect 的清理函数来手动调用 .cancel()。这不仅不符合 React 的声明式设计,也容易在复杂的生命周期中引入潜在的竞态问题。
建议:
使用 React.useMemo 配合 useRef 来缓存最新的 children 和 renderType,从而保持 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]);
| return () => { | ||
| renderDiagram.cancel(); | ||
| }; | ||
| }, [children, renderType, config]); |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
packages/x/components/mermaid/Mermaid.tsxpackages/x/components/mermaid/__tests__/index.test.tsx
| 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); | ||
| }); |
There was a problem hiding this comment.
🎯 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.
| 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.
Related Issue
Fixes #1947
Problem
Mermaid 组件流式渲染时,id 每次渲染都变化(uuid++ + children.length),导致 mermaid 创建的 DOM 节点反复 mount/unmount,引起页面抖动。
Changes
Test
Summary by CodeRabbit