From 678aa34a0ec1488ae62b85a87fefe8457c208102 Mon Sep 17 00:00:00 2001 From: Div627 Date: Fri, 24 Jul 2026 16:28:43 +0800 Subject: [PATCH] feat(x-markdown): add componentsProps to pass extra props to custom components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom components registered via `components` could only receive extra business data (theme, callbacks, etc.) through inline wrapper functions, which create a new component type on every render and force React to unmount/remount the whole subtree — losing internal state and hurting performance in streaming scenarios. `componentsProps` passes extra props by tag name while keeping component references stable. Extra props are merged over parsed HTML attributes at element creation; internally computed fields (children, and block / streamStatus / lang for code) still win. The generated reconciliation key is now always applied last so it can never be clobbered. close #1989 Co-Authored-By: Claude Fable 5 --- .../src/XMarkdown/__tests__/index.test.tsx | 91 +++++++++++++++++++ .../x-markdown/src/XMarkdown/core/Renderer.ts | 4 +- packages/x-markdown/src/XMarkdown/index.tsx | 4 +- .../x-markdown/src/XMarkdown/interface.ts | 7 ++ .../x/docs/x-markdown/components.en-US.md | 26 +++++- .../x/docs/x-markdown/components.zh-CN.md | 26 +++++- packages/x/docs/x-markdown/examples.en-US.md | 1 + packages/x/docs/x-markdown/examples.zh-CN.md | 1 + 8 files changed, 156 insertions(+), 4 deletions(-) diff --git a/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx b/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx index 2f792d5a4d..f6367d16bc 100644 --- a/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx +++ b/packages/x-markdown/src/XMarkdown/__tests__/index.test.tsx @@ -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 = (props) => { + receivedProps = props; + return {String(props.theme)}; + }; + const onSelect = jest.fn(); + const { container } = render( + '} + 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 = (props) => { + receivedTitle = props.title; + return widget; + }; + render( + '} + 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 = () => chart; + const Other: React.FC = (props) => { + otherProps = props; + return other; + }; + render( + '} + 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 = ({ step }) => { + React.useEffect(() => { + mountCount += 1; + }, []); + return widget; + }; + const content = 'before after'; + const components = { 'my-widget': Widget }; + const { container, rerender } = render( + , + ); + + expect(container.querySelector('[data-step="1"]')).toBeInTheDocument(); + + rerender( + , + ); + + expect(container.querySelector('[data-step="2"]')).toBeInTheDocument(); + expect(mountCount).toBe(1); + }); + }); + it('walkToken', () => { const walkTokens = (token: Token) => { if (token.type === 'heading') { diff --git a/packages/x-markdown/src/XMarkdown/core/Renderer.ts b/packages/x-markdown/src/XMarkdown/core/Renderer.ts index 74908f5d78..ef957d57a3 100644 --- a/packages/x-markdown/src/XMarkdown/core/Renderer.ts +++ b/packages/x-markdown/src/XMarkdown/core/Renderer.ts @@ -9,6 +9,7 @@ import { detectUnclosedComponentTags, getTagInstanceId } from './detectUnclosedC interface RendererOptions { components?: XMarkdownProps['components']; + componentsProps?: XMarkdownProps['componentsProps']; dompurifyConfig?: DOMPurifyConfig; streaming?: XMarkdownProps['streaming']; } @@ -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, }; // Handle class and className merging diff --git a/packages/x-markdown/src/XMarkdown/index.tsx b/packages/x-markdown/src/XMarkdown/index.tsx index 2002760617..1752d2fadc 100644 --- a/packages/x-markdown/src/XMarkdown/index.tsx +++ b/packages/x-markdown/src/XMarkdown/index.tsx @@ -12,6 +12,7 @@ const XMarkdown: React.FC = React.memo((props) => { streaming, config, components, + componentsProps, paragraphTag, content, children, @@ -91,10 +92,11 @@ const XMarkdown: React.FC = React.memo((props) => { () => new Renderer({ components: mergedComponents, + componentsProps, dompurifyConfig, streaming, }), - [mergedComponents, dompurifyConfig, streaming], + [mergedComponents, componentsProps, dompurifyConfig, streaming], ); const htmlString = useMemo(() => { diff --git a/packages/x-markdown/src/XMarkdown/interface.ts b/packages/x-markdown/src/XMarkdown/interface.ts index 9c172f59f6..2680999d0f 100644 --- a/packages/x-markdown/src/XMarkdown/interface.ts +++ b/packages/x-markdown/src/XMarkdown/interface.ts @@ -143,6 +143,13 @@ interface XMarkdownProps { components?: { [tagName: string]: React.ComponentType | 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; + }; /** * @description 流式渲染行为的配置 * @description Configuration for streaming rendering behavior diff --git a/packages/x/docs/x-markdown/components.en-US.md b/packages/x/docs/x-markdown/components.en-US.md index c3972ab689..de10d8d653 100644 --- a/packages/x/docs/x-markdown/components.en-US.md +++ b/packages/x/docs/x-markdown/components.en-US.md @@ -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` | - | +## 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 + , + }} +/>; + +// ✅ Stable component reference; extra data flows through componentsProps +; +``` + +`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. diff --git a/packages/x/docs/x-markdown/components.zh-CN.md b/packages/x/docs/x-markdown/components.zh-CN.md index 71d128978d..d4bf2f5803 100644 --- a/packages/x/docs/x-markdown/components.zh-CN.md +++ b/packages/x/docs/x-markdown/components.zh-CN.md @@ -31,9 +31,33 @@ import { Mermaid, Think, XMarkdown } from '@ant-design/x'; | children | 包裹在组件中的内容,包含 DOM 节点的文本内容 | `React.ReactNode` | - | | rest | 组件属性,支持所有标准 HTML 属性(如 `href`、`title`、`className` 等)和自定义数据属性 | `Record` | - | +## 传递额外的 props + +自定义组件常常需要接收业务数据(如主题、回调函数等)。如果通过内联函数传递,每次渲染都会产生新的组件引用,导致组件被反复卸载重建,在流式场景下会丢失内部状态并造成明显的性能损耗。使用 `componentsProps` 可以在保持组件引用稳定的同时传入额外的 props: + +```tsx +import React from 'react'; +import { XMarkdown } from '@ant-design/x'; + +// ❌ 内联函数:每次渲染都是新组件类型,子树整体重建 + , + }} +/>; + +// ✅ 组件引用稳定,额外数据通过 componentsProps 传入 +; +``` + +`componentsProps` 以标签名为 key,对应的 props 会与解析出的 HTML 属性合并后传给组件(同名时 `componentsProps` 优先)。`componentsProps` 变化时组件只会正常更新 props,不会被重新挂载。 + ## 最佳实践 -1. 保持组件引用稳定,避免在 `components` 中写内联函数组件。 +1. 保持组件引用稳定,避免在 `components` 中写内联函数组件;需要传递额外数据时使用 `componentsProps`。 2. 使用 `streamStatus` 区分加载态(`loading`)和完成态(`done`)。 3. 依赖完整语法的数据解析,尽量在 `streamStatus === 'done'` 后执行。 4. 自定义标签命名尽量语义化,减少 Markdown 与 HTML 混写歧义。 diff --git a/packages/x/docs/x-markdown/examples.en-US.md b/packages/x/docs/x-markdown/examples.en-US.md index a5a5aa2388..1959d8ee7d 100644 --- a/packages/x/docs/x-markdown/examples.en-US.md +++ b/packages/x/docs/x-markdown/examples.en-US.md @@ -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 \| 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>` | - | | 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` | - | diff --git a/packages/x/docs/x-markdown/examples.zh-CN.md b/packages/x/docs/x-markdown/examples.zh-CN.md index 87239a4e49..45f8c81f6c 100644 --- a/packages/x/docs/x-markdown/examples.zh-CN.md +++ b/packages/x/docs/x-markdown/examples.zh-CN.md @@ -27,6 +27,7 @@ packageName: x-markdown | content | 需要渲染的 Markdown 内容 | `string` | - | | children | Markdown 内容(与 `content` 二选一) | `string` | - | | components | 将 HTML 节点映射为自定义 React 组件 | `Record \| keyof JSX.IntrinsicElements>` | - | +| componentsProps | 按标签名向 `components` 中的自定义组件传递额外 props,保持组件引用稳定,避免内联函数导致的重复挂载 | `Record>` | - | | streaming | 流式渲染行为配置 | `StreamingOption` | - | | config | Marked 解析配置,后应用且可能覆盖内置 renderer | [`MarkedExtension`](https://marked.js.org/using_advanced#options) | `{ gfm: true }` | | rootClassName | 根元素的额外 CSS 类名 | `string` | - |