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 000000000..b3cfe1621 --- /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/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx index e1cff6a5c..6ec73bb18 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; } } 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 000000000..bebfe23bc --- /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 8c72672bb..e30067bfb 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 a828fa3a9..ea27fe50c 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