Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,97 @@ describe('XMarkdown', () => {
expect((container.firstChild as HTMLElement)?.innerHTML).toBe(html);
});

describe('componentsProps', () => {
it('passes extra props to the matching custom component', () => {
let receivedProps: ComponentProps | undefined;
const Chart: React.FC<ComponentProps> = (props) => {
receivedProps = props;
return <span>{String(props.theme)}</span>;
};
const onSelect = jest.fn();
const { container } = render(
<XMarkdown
content={'<custom-chart data-id="1"></custom-chart>'}
components={{ 'custom-chart': Chart }}
componentsProps={{ 'custom-chart': { theme: 'dark', onSelect } }}
/>,
);

expect(container.querySelector('span')?.textContent).toBe('dark');
expect(receivedProps?.theme).toBe('dark');
expect(receivedProps?.onSelect).toBe(onSelect);
expect(receivedProps?.['data-id']).toBe('1');
expect(receivedProps?.streamStatus).toBe('done');
});

it('extra props take precedence over parsed HTML attributes', () => {
let receivedTitle: unknown;
const Widget: React.FC<ComponentProps> = (props) => {
receivedTitle = props.title;
return <span>widget</span>;
};
render(
<XMarkdown
content={'<my-widget title="from-html"></my-widget>'}
components={{ 'my-widget': Widget }}
componentsProps={{ 'my-widget': { title: 'from-props' } }}
/>,
);

expect(receivedTitle).toBe('from-props');
});

it('does not leak props into other custom components', () => {
let otherProps: ComponentProps | undefined;
const Chart: React.FC<ComponentProps> = () => <span>chart</span>;
const Other: React.FC<ComponentProps> = (props) => {
otherProps = props;
return <span>other</span>;
};
render(
<XMarkdown
content={'<custom-chart></custom-chart><custom-other></custom-other>'}
components={{ 'custom-chart': Chart, 'custom-other': Other }}
componentsProps={{ 'custom-chart': { theme: 'dark' } }}
/>,
);

expect(otherProps?.theme).toBeUndefined();
});

it('keeps the custom component mounted when componentsProps changes', () => {
let mountCount = 0;
const Widget: React.FC<ComponentProps> = ({ step }) => {
React.useEffect(() => {
mountCount += 1;
}, []);
return <span data-step={String(step)}>widget</span>;
};
const content = 'before <my-widget></my-widget> after';
const components = { 'my-widget': Widget };
const { container, rerender } = render(
<XMarkdown
content={content}
components={components}
componentsProps={{ 'my-widget': { step: 1 } }}
/>,
);

expect(container.querySelector('[data-step="1"]')).toBeInTheDocument();

rerender(
<XMarkdown
content={content}
components={components}
componentsProps={{ 'my-widget': { step: 2 } }}
/>,
);

expect(container.querySelector('[data-step="2"]')).toBeInTheDocument();
expect(mountCount).toBe(1);
});
});

it('walkToken', () => {
const walkTokens = (token: Token) => {
if (token.type === 'heading') {
Expand Down
4 changes: 3 additions & 1 deletion packages/x-markdown/src/XMarkdown/core/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { detectUnclosedComponentTags, getTagInstanceId } from './detectUnclosedC

interface RendererOptions {
components?: XMarkdownProps['components'];
componentsProps?: XMarkdownProps['componentsProps'];
dompurifyConfig?: DOMPurifyConfig;
streaming?: XMarkdownProps['streaming'];
}
Expand Down Expand Up @@ -199,10 +200,11 @@ class Renderer {
const props: ComponentProps = {
domNode,
streamStatus,
key,
...attribs,
...(attribs.disabled !== undefined && { disabled: true }),
...(attribs.checked !== undefined && { checked: true }),
...this.options.componentsProps?.[name],
key,
Comment on lines +206 to +207

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 | 🟠 Major | ⚡ Quick win

修正 componentsProps 的优先级顺序。

当前实现允许额外 props 覆盖内部生成的 domNodestreamStatus;同时,componentsProps.className 会与 HTML 的 class 拼接而非覆盖。应先合并解析属性与额外 props,再写入内部字段,并让额外 class 属性覆盖解析出的 class。补充这些冲突场景的测试。

🤖 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-markdown/src/XMarkdown/core/Renderer.ts` around lines 206 - 207,
调整 Renderer 中构造组件 props 的合并顺序:先合并解析出的属性与 componentsProps,再写入内部生成的 domNode 和
streamStatus,确保额外 props 无法覆盖内部字段。处理 className 时让 componentsProps.className 覆盖
HTML 解析出的 class,而不是进行拼接。补充覆盖 domNode、streamStatus 及 className 冲突场景的测试。

};

// Handle class and className merging
Expand Down
4 changes: 3 additions & 1 deletion packages/x-markdown/src/XMarkdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const XMarkdown: React.FC<XMarkdownProps> = React.memo((props) => {
streaming,
config,
components,
componentsProps,
paragraphTag,
content,
children,
Expand Down Expand Up @@ -91,10 +92,11 @@ const XMarkdown: React.FC<XMarkdownProps> = React.memo((props) => {
() =>
new Renderer({
components: mergedComponents,
componentsProps,
dompurifyConfig,
streaming,
}),
[mergedComponents, dompurifyConfig, streaming],
[mergedComponents, componentsProps, dompurifyConfig, streaming],
);

const htmlString = useMemo(() => {
Expand Down
7 changes: 7 additions & 0 deletions packages/x-markdown/src/XMarkdown/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ interface XMarkdownProps {
components?: {
[tagName: string]: React.ComponentType<ComponentProps> | keyof JSX.IntrinsicElements;
};
/**
* @description 按标签名向 `components` 中的自定义组件传递额外的 props,使组件引用保持稳定,避免内联函数导致的重复挂载
* @description Extra props passed to custom components in `components` by tag name, keeping component references stable and avoiding remounts caused by inline functions
*/
componentsProps?: {
[tagName: string]: Record<string, unknown>;
};
/**
* @description 流式渲染行为的配置
* @description Configuration for streaming rendering behavior
Expand Down
26 changes: 25 additions & 1 deletion packages/x/docs/x-markdown/components.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,33 @@ import { Mermaid, Think, XMarkdown } from '@ant-design/x';
| children | Content wrapped in the component, containing the text content of DOM nodes | `React.ReactNode` | - |
| rest | Component properties, supports all standard HTML attributes (such as `href`, `title`, `className`, etc.) and custom data attributes | `Record<string, any>` | - |

## Passing extra props

Custom components often need business data (theme, callbacks, etc.). Passing it via an inline function creates a new component reference on every render, so React unmounts and remounts the whole subtree — losing internal state and hurting performance in streaming scenarios. Use `componentsProps` to pass extra props while keeping component references stable:

```tsx
import React from 'react';
import { XMarkdown } from '@ant-design/x';

// ❌ Inline function: a new component type every render, the subtree is rebuilt
<XMarkdown
components={{
'custom-chart': (props) => <CustomChart {...props} theme={theme} onSelect={onSelect} />,
}}
/>;

// ✅ Stable component reference; extra data flows through componentsProps
<XMarkdown
components={{ 'custom-chart': CustomChart }}
componentsProps={{ 'custom-chart': { theme, onSelect } }}
/>;
```

`componentsProps` is keyed by tag name. Its props are merged with the parsed HTML attributes and passed to the component (`componentsProps` wins on conflicts). When `componentsProps` changes, the component receives a normal props update without being remounted.

## Best Practices

1. Keep component references stable. Avoid inline function components in `components`.
1. Keep component references stable. Avoid inline function components in `components`; use `componentsProps` to pass extra data.
2. Use `streamStatus` to separate loading UI (`loading`) from finalized UI (`done`).
3. If data depends on complete syntax, fetch or parse after `streamStatus === 'done'`.
4. Keep custom tags semantically clear and avoid ambiguous mixed Markdown/HTML blocks.
Expand Down
26 changes: 25 additions & 1 deletion packages/x/docs/x-markdown/components.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,33 @@ import { Mermaid, Think, XMarkdown } from '@ant-design/x';
| children | 包裹在组件中的内容,包含 DOM 节点的文本内容 | `React.ReactNode` | - |
| rest | 组件属性,支持所有标准 HTML 属性(如 `href`、`title`、`className` 等)和自定义数据属性 | `Record<string, any>` | - |

## 传递额外的 props

自定义组件常常需要接收业务数据(如主题、回调函数等)。如果通过内联函数传递,每次渲染都会产生新的组件引用,导致组件被反复卸载重建,在流式场景下会丢失内部状态并造成明显的性能损耗。使用 `componentsProps` 可以在保持组件引用稳定的同时传入额外的 props:

```tsx
import React from 'react';
import { XMarkdown } from '@ant-design/x';

// ❌ 内联函数:每次渲染都是新组件类型,子树整体重建
<XMarkdown
components={{
'custom-chart': (props) => <CustomChart {...props} theme={theme} onSelect={onSelect} />,
}}
/>;

// ✅ 组件引用稳定,额外数据通过 componentsProps 传入
<XMarkdown
components={{ 'custom-chart': CustomChart }}
componentsProps={{ 'custom-chart': { theme, onSelect } }}
/>;
```

`componentsProps` 以标签名为 key,对应的 props 会与解析出的 HTML 属性合并后传给组件(同名时 `componentsProps` 优先)。`componentsProps` 变化时组件只会正常更新 props,不会被重新挂载。

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

同步修正文档中的 props 优先级说明。

Renderer 中 componentsProps 仅覆盖解析出的 HTML 属性;childrenkey 以及代码组件的 blocklangstreamStatus 会在之后由内部逻辑重新计算。当前两份文档都表述为所有冲突均由 componentsProps 覆盖,需改为明确列出内部字段仍保持优先级。

  • packages/x/docs/x-markdown/components.zh-CN.md#L56-L56: 将“同名时 componentsProps 优先”限定为解析出的 HTML 属性。
  • packages/x/docs/x-markdown/components.en-US.md#L56-L56: 同步英文表述,说明内部计算字段不会被覆盖。
📍 Affects 2 files
  • packages/x/docs/x-markdown/components.zh-CN.md#L56-L56 (this comment)
  • packages/x/docs/x-markdown/components.en-US.md#L56-L56
🤖 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/docs/x-markdown/components.zh-CN.md` at line 56, Update the
props-priority wording at packages/x/docs/x-markdown/components.zh-CN.md:56-56
to state that componentsProps only overrides parsed HTML attributes, while
internally computed children, key, and code-component fields block, lang, and
streamStatus retain priority. Apply the equivalent clarification at
packages/x/docs/x-markdown/components.en-US.md:56-56, keeping both language
versions consistent.


## 最佳实践

1. 保持组件引用稳定,避免在 `components` 中写内联函数组件。
1. 保持组件引用稳定,避免在 `components` 中写内联函数组件;需要传递额外数据时使用 `componentsProps`
2. 使用 `streamStatus` 区分加载态(`loading`)和完成态(`done`)。
3. 依赖完整语法的数据解析,尽量在 `streamStatus === 'done'` 后执行。
4. 自定义标签命名尽量语义化,减少 Markdown 与 HTML 混写歧义。
Expand Down
1 change: 1 addition & 0 deletions packages/x/docs/x-markdown/examples.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Use this page to get a minimal setup for rendering LLM Markdown output.
| content | Markdown content to render | `string` | - |
| children | Markdown content (use either `content` or `children`) | `string` | - |
| components | Map HTML nodes to custom React components | `Record<string, React.ComponentType<ComponentProps> \| keyof JSX.IntrinsicElements>` | - |
| componentsProps | Extra props passed to custom components in `components` by tag name, keeping component references stable and avoiding remounts caused by inline functions | `Record<string, Record<string, unknown>>` | - |
| streaming | Streaming behavior config | `StreamingOption` | - |
| config | Marked parse config, applied last and may override built-in renderers | [`MarkedExtension`](https://marked.js.org/using_advanced#options) | `{ gfm: true }` |
| rootClassName | Extra CSS class for the root element | `string` | - |
Expand Down
1 change: 1 addition & 0 deletions packages/x/docs/x-markdown/examples.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ packageName: x-markdown
| content | 需要渲染的 Markdown 内容 | `string` | - |
| children | Markdown 内容(与 `content` 二选一) | `string` | - |
| components | 将 HTML 节点映射为自定义 React 组件 | `Record<string, React.ComponentType<ComponentProps> \| keyof JSX.IntrinsicElements>` | - |
| componentsProps | 按标签名向 `components` 中的自定义组件传递额外 props,保持组件引用稳定,避免内联函数导致的重复挂载 | `Record<string, Record<string, unknown>>` | - |
| streaming | 流式渲染行为配置 | `StreamingOption` | - |
| config | Marked 解析配置,后应用且可能覆盖内置 renderer | [`MarkedExtension`](https://marked.js.org/using_advanced#options) | `{ gfm: true }` |
| rootClassName | 根元素的额外 CSS 类名 | `string` | - |
Expand Down
Loading