From 9a58bbcf5ccf96b4eb184f4e54e4328439573d00 Mon Sep 17 00:00:00 2001 From: "liaojinhao.ljh" Date: Thu, 16 Jul 2026 10:12:19 +0800 Subject: [PATCH] fix(mermaid): fix streaming render jitter by stabilizing id and using 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 #1947 --- packages/x/components/mermaid/Mermaid.tsx | 23 ++-- .../mermaid/__tests__/index.test.tsx | 105 +++++++++++++++++- 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/packages/x/components/mermaid/Mermaid.tsx b/packages/x/components/mermaid/Mermaid.tsx index 1cc3437bc5..7ecb16bd45 100644 --- a/packages/x/components/mermaid/Mermaid.tsx +++ b/packages/x/components/mermaid/Mermaid.tsx @@ -1,9 +1,9 @@ import { DownloadOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons'; import { Button, Segmented, Tooltip } from 'antd'; import { clsx } from 'clsx'; -import throttle from 'lodash.throttle'; +import { debounce } from 'lodash'; import mermaid, { type MermaidConfig } from 'mermaid'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useId, useRef, useState } from 'react'; import useXComponentConfig from '../_util/hooks/use-x-component-config'; import warning from '../_util/warning'; import Actions from '../actions'; @@ -42,8 +42,6 @@ enum RenderType { Image = 'image', } -let uuid = 0; - const Mermaid: React.FC = React.memo((props) => { const { prefixCls: customizePrefixCls, @@ -64,7 +62,8 @@ const Mermaid: React.FC = React.memo((props) => { const [isDragging, setIsDragging] = useState(false); const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); - const id = `mermaid-${uuid++}-${children?.length || 0}`; + const reactId = useId(); + const id = `mermaid-${reactId.replace(/:/g, '')}`; // ============================ locale ============================ const [contextLocale] = useLocale('Mermaid', locale_EN.Mermaid); @@ -103,19 +102,22 @@ const Mermaid: React.FC = React.memo((props) => { }, [config]); // ============================ render mermaid ============================ - const renderDiagram = throttle(async () => { + 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); useEffect(() => { if (renderType === RenderType.Code && containerRef.current) { @@ -124,6 +126,9 @@ const Mermaid: React.FC = React.memo((props) => { } else { renderDiagram(); } + return () => { + renderDiagram.cancel(); + }; }, [children, renderType, config]); useEffect(() => { diff --git a/packages/x/components/mermaid/__tests__/index.test.tsx b/packages/x/components/mermaid/__tests__/index.test.tsx index 7473e9d534..2859b0cab0 100644 --- a/packages/x/components/mermaid/__tests__/index.test.tsx +++ b/packages/x/components/mermaid/__tests__/index.test.tsx @@ -899,7 +899,7 @@ describe('Mermaid Component', () => { }); describe('Performance Tests', () => { - it('should throttle render calls', async () => { + it('should debounce render calls', async () => { const { rerender } = render({mermaidContent}); // 快速连续改变内容 @@ -908,7 +908,7 @@ describe('Mermaid Component', () => { } await waitFor(() => { - // 由于节流,render调用次数应该少于内容变化次数 + // 由于防抖,连续快速更新后最终只触发一次渲染 expect(mockRender).toHaveBeenCalled(); }); }); @@ -1206,4 +1206,105 @@ describe('Mermaid Component', () => { expect(element).toBeInTheDocument(); }); }); + + // Regression tests for issue #1947: Mermaid streaming render jitter + describe('Streaming Render Stability (issue #1947)', () => { + it('should keep stable id across re-renders', async () => { + const { rerender } = render({mermaidContent}); + + await waitFor(() => { + expect(mockRender).toHaveBeenCalled(); + }); + + const firstCallId = mockRender.mock.calls[0][0]; + + // Simulate streaming content change + rerender({`${mermaidContent}\n B-->C;`}); + + await waitFor(() => { + expect(mockRender).toHaveBeenCalledTimes(2); + }); + + const secondCallId = mockRender.mock.calls[1][0]; + + // id should be the same across re-renders + expect(secondCallId).toBe(firstCallId); + }); + + it('should not use uuid++ that changes every render', async () => { + const { rerender } = render({mermaidContent}); + + 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({`${mermaidContent} ${i}`}); + 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 have different ids for different component instances', async () => { + render( +
+ {mermaidContent} + {mermaidContent} +
, + ); + + await waitFor(() => { + expect(mockRender).toHaveBeenCalledTimes(2); + }); + + const id1 = mockRender.mock.calls[0][0]; + const id2 = mockRender.mock.calls[1][0]; + + // Different instances should have different ids + expect(id1).not.toBe(id2); + }); + + it('should skip render when mermaid syntax is incomplete (streaming)', async () => { + // Simulate incomplete syntax during streaming + mockParse.mockResolvedValue(false); + + const incompleteCode = 'graph TD; A--' + '>'; + const { rerender } = render({incompleteCode}); + + // Wait for debounce + await waitFor(() => { + expect(mockParse).toHaveBeenCalled(); + }); + + // mockRender should NOT be called because syntax is invalid + expect(mockRender).not.toHaveBeenCalled(); + + // Now syntax becomes complete + mockParse.mockResolvedValue(true); + const completeCode = 'graph TD; A--' + '>B;'; + rerender({completeCode}); + + await waitFor(() => { + expect(mockRender).toHaveBeenCalled(); + }); + }); + + it('should cancel pending debounced render on unmount', async () => { + const { unmount } = render({mermaidContent}); + + // Unmount before debounce fires + expect(() => unmount()).not.toThrow(); + }); + }); });