From b85af6532f31d5e901568e74da790ae5eef08722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A9=E6=B8=85?= Date: Thu, 16 Jul 2026 21:58:16 -0700 Subject: [PATCH 1/2] test(x-markdown): add loading isolation tests and demo for #1949 Add unit tests verifying streamStatus is isolated per component instance: - Renderer-level: different/same tag names, interleaved, nested, self-closing - Full pipeline: Parser + DOMPurify + html-react-parser - XMarkdown component: streaming loading/done transitions Add demo showing multiple custom components with independent loading states. Closes #1949 --- .../__tests__/loading-isolation.test.tsx | 361 ++++++++++++++++++ .../streaming/multi-component-loading.tsx | 162 ++++++++ packages/x/docs/x-markdown/streaming.en-US.md | 2 +- packages/x/docs/x-markdown/streaming.zh-CN.md | 2 +- 4 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 packages/x-markdown/src/XMarkdown/__tests__/loading-isolation.test.tsx create mode 100644 packages/x/docs/x-markdown/demo/streaming/multi-component-loading.tsx diff --git a/packages/x-markdown/src/XMarkdown/__tests__/loading-isolation.test.tsx b/packages/x-markdown/src/XMarkdown/__tests__/loading-isolation.test.tsx new file mode 100644 index 0000000000..b3cfe1621b --- /dev/null +++ b/packages/x-markdown/src/XMarkdown/__tests__/loading-isolation.test.tsx @@ -0,0 +1,361 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import XMarkdown from '../index'; +import { Parser, Renderer } from '../core'; +import type { ComponentProps } from '../interface'; + +/** + * Tests for Issue #1949: XMarkdown 多个自定义组件同时进入 loading + * + * Verifies that streamStatus (loading/done) is isolated per component instance, + * not shared across all instances of the same tag name or across different tag names. + * + * Background: + * Before the fix (PR #1590), `detectUnclosedTags` returned a Set of tag NAMES + * (e.g. "my-comp"), and `createReplaceElement` checked `unclosedTags.has(name)`. + * If ANY instance of a tag was unclosed, ALL instances of that tag (and potentially + * all custom components) would receive `streamStatus: 'loading'`. + * + * After the fix, `detectUnclosedComponentTags` returns a Set of instance IDs + * (e.g. "my-comp-2"), and `createReplaceElement` checks + * `unclosedTags.has(getTagInstanceId(name, tagIndex))`, isolating loading state + * per component instance. + */ + +const MockComponent: React.FC = (props) => { + return React.createElement( + 'div', + { 'data-stream-status': props.streamStatus }, + props.children, + ); +}; + +// Track streamStatus for each render of a component +interface StreamStatusRecord { + name: string; + streamStatus: string; +} + +describe('Loading isolation (Issue #1949)', () => { + // ============================================================ + // Renderer-level tests: verify createReplaceElement isolates + // streamStatus per instance + // ============================================================ + describe('Renderer: per-instance streamStatus isolation', () => { + it('should mark only the unclosed instance as loading (different tag names)', () => { + const components = { 'comp-a': MockComponent, 'comp-b': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + // comp-a is closed, comp-b is unclosed + renderer.processHtml('AB'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + const a = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-a'); + const b = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-b'); + + expect(a[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(b[0][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + + it('should mark only the unclosed instance as loading (same tag name)', () => { + const components = { 'my-comp': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + // First instance is closed, second is unclosed + renderer.processHtml('DoneLoading'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + expect(calls).toHaveLength(2); + + expect(calls[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(calls[1][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + + it('should isolate loading for 3+ components with only the last one loading', () => { + const components = { + 'comp-a': MockComponent, + 'comp-b': MockComponent, + 'comp-c': MockComponent, + }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + renderer.processHtml('ABC'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + expect(calls).toHaveLength(3); + + expect(calls[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(calls[1][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(calls[2][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + + it('should handle interleaved same-name components correctly', () => { + const components = { 'comp-a': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + // A1 closed, A2 closed, A3 unclosed + renderer.processHtml('123'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + expect(calls).toHaveLength(3); + + expect(calls[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(calls[1][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(calls[2][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + + it('should handle interleaved different-name components correctly', () => { + const components = { 'comp-a': MockComponent, 'comp-b': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + // a closed, b closed, a closed, b unclosed + renderer.processHtml('A1B1A2B2'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + const aCalls = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-a'); + const bCalls = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-b'); + + expect(aCalls).toHaveLength(2); + expect(bCalls).toHaveLength(2); + + expect(aCalls[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(aCalls[1][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(bCalls[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(bCalls[1][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + + it('should mark all as done when all tags are closed', () => { + const components = { 'comp-a': MockComponent, 'comp-b': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + renderer.processHtml('AB'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + expect(calls).toHaveLength(2); + expect(calls[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(calls[1][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + + spy.mockRestore(); + }); + + it('should handle self-closing tag followed by unclosed tag', () => { + const components = { 'comp-a': MockComponent, 'comp-b': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + renderer.processHtml('B'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + const a = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-a'); + const b = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-b'); + + expect(a[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(b[0][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + + it('should handle nested unclosed parent with closed child', () => { + const components = { 'comp-a': MockComponent, 'comp-b': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + // comp-a unclosed, comp-b closed inside comp-a + renderer.processHtml('textinner'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + const a = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-a'); + const b = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-b'); + + // Parent should be loading (unclosed), child should be done (closed) + expect(a[0][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + expect(b[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + + spy.mockRestore(); + }); + + it('should handle deep nesting with mixed states', () => { + const components = { 'comp-a': MockComponent }; + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + // Level 1 open, Level 2 open, Level 3 closed + renderer.processHtml('123'); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + expect(calls).toHaveLength(3); + + // Innermost (Level 3) processed first - done + expect(calls[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + // Level 2 - loading + expect(calls[1][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + // Level 1 - loading + expect(calls[2][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + }); + + // ============================================================ + // Full pipeline tests: verify Parser + Renderer isolates + // streamStatus through the complete markdown → HTML → React + // pipeline + // ============================================================ + describe('Full pipeline (Parser + Renderer): loading isolation', () => { + it('should isolate loading through marked parsing and DOMPurify sanitization', () => { + const components = { + 'comp-a': MockComponent, + 'comp-b': MockComponent, + 'comp-c': MockComponent, + }; + const parser = new Parser({ components }); + const renderer = new Renderer({ components }); + const spy = jest.spyOn(React, 'createElement'); + + // comp-a and comp-b are closed, comp-c is unclosed + const markdown = 'A\n\nB\n\nC'; + const html = parser.parse(markdown); + renderer.render(html); + + const calls = spy.mock.calls.filter((c) => c[0] === MockComponent); + const a = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-a'); + const b = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-b'); + const c = calls.filter((c) => (c[1] as any).domNode?.name === 'comp-c'); + + expect(a[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(b[0][1]).toEqual(expect.objectContaining({ streamStatus: 'done' })); + expect(c[0][1]).toEqual(expect.objectContaining({ streamStatus: 'loading' })); + + spy.mockRestore(); + }); + }); + + // ============================================================ + // XMarkdown component tests: verify the full component + // correctly isolates loading during streaming + // ============================================================ + describe('XMarkdown component: streaming loading isolation', () => { + it('should show loading only for the unclosed component during streaming', () => { + const records: StreamStatusRecord[] = []; + + const CompA: React.FC = (props) => { + records.push({ name: 'comp-a', streamStatus: props.streamStatus }); + return React.createElement('div', { 'data-status': props.streamStatus }, props.children); + }; + const CompB: React.FC = (props) => { + records.push({ name: 'comp-b', streamStatus: props.streamStatus }); + return React.createElement('div', { 'data-status': props.streamStatus }, props.children); + }; + + // comp-a is fully closed, comp-b is still streaming (unclosed) + render( + , + ); + + const aRecords = records.filter((r) => r.name === 'comp-a'); + const bRecords = records.filter((r) => r.name === 'comp-b'); + + expect(aRecords.length).toBeGreaterThan(0); + expect(aRecords[aRecords.length - 1].streamStatus).toBe('done'); + + expect(bRecords.length).toBeGreaterThan(0); + expect(bRecords[bRecords.length - 1].streamStatus).toBe('loading'); + }); + + it('should independently complete loading when streaming finishes', () => { + const records: StreamStatusRecord[] = []; + + const CompA: React.FC = (props) => { + records.push({ name: 'comp-a', streamStatus: props.streamStatus }); + return React.createElement('div', { 'data-status': props.streamStatus }, props.children); + }; + const CompB: React.FC = (props) => { + records.push({ name: 'comp-b', streamStatus: props.streamStatus }); + return React.createElement('div', { 'data-status': props.streamStatus }, props.children); + }; + + const components = { 'comp-a': CompA, 'comp-b': CompB }; + + // Start with comp-b unclosed + const { rerender } = render( + , + ); + + // Verify comp-b was loading + const midB = records.filter((r) => r.name === 'comp-b'); + expect(midB[midB.length - 1].streamStatus).toBe('loading'); + + // Clear records and complete comp-b + records.length = 0; + rerender( + , + ); + + // Both should be done after streaming completes + const finalA = records.filter((r) => r.name === 'comp-a'); + const finalB = records.filter((r) => r.name === 'comp-b'); + expect(finalA[finalA.length - 1].streamStatus).toBe('done'); + expect(finalB[finalB.length - 1].streamStatus).toBe('done'); + }); + + it('should handle multiple same-type components with different loading states', () => { + const records: StreamStatusRecord[] = []; + + const MyComp: React.FC = (props) => { + records.push({ name: 'my-comp', streamStatus: props.streamStatus }); + return React.createElement('div', { 'data-status': props.streamStatus }, props.children); + }; + + // First instance is closed, second is unclosed (still streaming) + render( + First complete\n\nText\n\nSecond streaming'} + streaming={{ hasNextChunk: true }} + components={{ 'my-comp': MyComp }} + paragraphTag="div" + />, + ); + + expect(records.length).toBe(2); + expect(records[0].streamStatus).toBe('done'); + expect(records[1].streamStatus).toBe('loading'); + }); + }); +}); \ No newline at end of file diff --git a/packages/x/docs/x-markdown/demo/streaming/multi-component-loading.tsx b/packages/x/docs/x-markdown/demo/streaming/multi-component-loading.tsx new file mode 100644 index 0000000000..bebfe23bcd --- /dev/null +++ b/packages/x/docs/x-markdown/demo/streaming/multi-component-loading.tsx @@ -0,0 +1,162 @@ +import { Bubble } from '@ant-design/x'; +import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown'; +import { Button, Flex, Skeleton, Tag, theme } from 'antd'; +import React from 'react'; +import '@ant-design/x-markdown/themes/light.css'; +import '@ant-design/x-markdown/themes/dark.css'; + +/** + * Demo: Multiple custom components with independent loading states (Issue #1949) + * + * During streaming, each custom component should independently enter/exit the + * loading state based on whether its own closing tag has arrived — not all + * components loading together when only one is still streaming. + */ + +// A custom component that shows different content based on streamStatus +const DataCard = React.memo(({ children, streamStatus }: ComponentProps) => { + if (streamStatus === 'loading') { + return ( +
+ +
+ ); + } + + return ( +
+
+ Data Card done +
+
{children}
+
+ ); +}); + +// Another custom component type to demonstrate cross-type isolation +const InfoPanel = React.memo(({ children, streamStatus }: ComponentProps) => { + if (streamStatus === 'loading') { + return ( +
+ +
+ ); + } + + return ( +
+
+ Info Panel done +
+
{children}
+
+ ); +}); + +// Markdown content with multiple custom components +const text = `Here are multiple custom components rendered independently: + +Component A: This is the first data card content. + +Some text between components. + +Component B: This info panel downloads independently. + +More text. + +Component C: Another data card with its own loading state. +`; + +const App = () => { + const [index, setIndex] = React.useState(0); + const [isStreaming, setIsStreaming] = React.useState(false); + const timer = React.useRef | null>(null); + const contentRef = React.useRef(null); + const { theme: antdTheme } = theme.useToken(); + const className = antdTheme.id === 0 ? 'x-markdown-light' : 'x-markdown-dark'; + + React.useEffect(() => { + if (timer.current) clearTimeout(timer.current); + + if (index >= text.length) { + setIsStreaming(false); + return; + } + + setIsStreaming(true); + timer.current = setTimeout(() => { + setIndex((prev) => Math.min(prev + 3, text.length)); + }, 30); + + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, [index]); + + React.useEffect(() => { + if (contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight; + } + }, [index]); + + return ( + + + + + +
+ ( + + )} + variant="outlined" + /> +
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/packages/x/docs/x-markdown/streaming.en-US.md b/packages/x/docs/x-markdown/streaming.en-US.md index 8c72672bbe..e30067bfbc 100644 --- a/packages/x/docs/x-markdown/streaming.en-US.md +++ b/packages/x/docs/x-markdown/streaming.en-US.md @@ -7,7 +7,7 @@ Handle **LLM streamed Markdown** output: syntax completion and caching, animatio ## Code Examples -Syntax Processing Rendering Controls +Syntax Processing Rendering Controls Multi-Component Loading Isolation ## API diff --git a/packages/x/docs/x-markdown/streaming.zh-CN.md b/packages/x/docs/x-markdown/streaming.zh-CN.md index a828fa3a99..ea27fe50c3 100644 --- a/packages/x/docs/x-markdown/streaming.zh-CN.md +++ b/packages/x/docs/x-markdown/streaming.zh-CN.md @@ -7,7 +7,7 @@ order: 4 ## 代码示例 -语法处理 渲染控制 +语法处理 渲染控制 多组件 Loading 隔离 ## API From 794a77ba745dace4ec9e722e4435e713bfab93f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A9=E6=B8=85?= Date: Fri, 17 Jul 2026 01:16:52 -0700 Subject: [PATCH 2/2] fix(sender): trigger onClose when removing skill via Backspace In handleDeleteOperation, the Backspace key deletion branch for skill nodes only called removeSkill() without firing the closable.onClose callback. This was inconsistent with the click-close path in Skill.tsx which fires both removeSkill() and config.onClose?.(event). Fix: fire closable.onClose after removeSkill() in the keyboard deletion branch to match the click-close behavior. Closes #1955 --- .../components/sender/__tests__/slot.test.tsx | 41 +++++++++++++++++++ .../sender/components/SlotTextArea.tsx | 3 ++ 2 files changed, 44 insertions(+) diff --git a/packages/x/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx index e1cff6a5cd..6ec73bb180 100644 --- a/packages/x/components/sender/__tests__/slot.test.tsx +++ b/packages/x/components/sender/__tests__/slot.test.tsx @@ -523,6 +523,47 @@ describe('Sender Slot Component', () => { setupDOMMocks(customSelectionMock, customRangeMock); fireEvent.keyDown(dom, { key: 'Backspace' }); }); + it('should trigger onClose when skill is removed via Backspace', () => { + const mockOnClose = jest.fn(); + const ref = createRef(); + const { container } = render( + , + ); + const dom = ref.current?.inputElement as HTMLElement; + const skillDom = container.querySelector('.ant-sender-skill') as HTMLElement; + expect(ref.current).toBeDefined(); + expect(skillDom).toBeDefined(); + + const customSelectionMock = { + rangeCount: 1, + focusOffset: 0, + anchorNode: dom, + removeAllRanges: jest.fn(), + addRange: jest.fn(), + }; + + Object.defineProperty(dom, 'previousSibling', { + value: skillDom, + configurable: true, + }); + + const customRangeMock = createMockRange(); + setupDOMMocks(customSelectionMock, customRangeMock); + fireEvent.keyDown(dom, { key: 'Backspace' }); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); it('should handle skill removal and addition', () => { const { rerender, container } = render( ((_, ref) => { if (skillKey) { e.preventDefault(); removeSkill(); + // Fire onClose callback to match click-close behavior (Skill.tsx) + const closableConfig = typeof skill?.closable === 'boolean' ? {} : skill?.closable; + closableConfig?.onClose?.(e as unknown as React.MouseEvent); return true; } }