diff --git a/src/Body/MeasureCell.tsx b/src/Body/MeasureCell.tsx index 11b861148..e747b0148 100644 --- a/src/Body/MeasureCell.tsx +++ b/src/Body/MeasureCell.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import ResizeObserver from 'rc-resize-observer'; import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; +import { sanitizeCloneElement } from '../utils/reactNodeUtil'; export interface MeasureCellProps { columnKey: React.Key; @@ -26,7 +27,9 @@ export default function MeasureCell({ return ( -
{title || '\xa0'}
+
+ {sanitizeCloneElement(title) || '\xa0'} +
); diff --git a/src/Body/MeasureRow.tsx b/src/Body/MeasureRow.tsx index c0fda2e52..3ca9c6b6f 100644 --- a/src/Body/MeasureRow.tsx +++ b/src/Body/MeasureRow.tsx @@ -35,17 +35,13 @@ export default function MeasureRow({ > {columnsKey.map(columnKey => { const column = columns.find(col => col.key === columnKey); - const rawTitle = column?.title; - const titleForMeasure = React.isValidElement>(rawTitle) - ? React.cloneElement(rawTitle, { ref: null }) - : rawTitle; return ( ); })} diff --git a/src/utils/__tests__/reactNodeUtil.test.tsx b/src/utils/__tests__/reactNodeUtil.test.tsx new file mode 100644 index 000000000..9ce62ee45 --- /dev/null +++ b/src/utils/__tests__/reactNodeUtil.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { sanitizeCloneElement } from '../reactNodeUtil'; + +describe('reactNodeUtil', () => { + describe('sanitizeCloneElement', () => { + it('should remove filtered props', () => { + const node = React.createElement( + 'div', + { + id: 'test-id', + 'data-test': 'test-data', + onFocus: () => {}, + onBlur: () => {}, + tabIndex: 0, + className: 'test-class', + }, + 'test content', + ); + + const result = sanitizeCloneElement(node) as React.ReactElement; + + expect(result.props.id).toBeUndefined(); + expect(result.props['data-test']).toBeUndefined(); + expect(result.props.onFocus).toBeUndefined(); + expect(result.props.onBlur).toBeUndefined(); + expect(result.props.tabIndex).toBeUndefined(); + expect(result.props.className).toBe('test-class'); + expect(result.props.children).toBe('test content'); + }); + + it('should handle nested elements', () => { + const nested = React.createElement('span', { id: 'nested-id' }, 'nested'); + const parent = React.createElement( + 'div', + { + id: 'parent-id', + className: 'parent', + }, + nested, + ); + + const result = sanitizeCloneElement(parent) as React.ReactElement; + + expect(result.props.id).toBeUndefined(); + expect(result.props.className).toBe('parent'); + + const childElement = result.props.children as React.ReactElement; + expect(childElement.props.id).toBeUndefined(); + expect(childElement.props.children).toBe('nested'); + }); + + it('should limit recursion depth', () => { + // 创建深层嵌套结构 + let deepNode: React.ReactElement = React.createElement('span', { id: 'deep' }, 'content'); + + for (let i = 0; i < 15; i++) { + deepNode = React.createElement('div', { id: `level-${i}` }, deepNode); + } + + const result = sanitizeCloneElement(deepNode, 0, 5); + expect(result).toBeDefined(); + // 应该在递归深度限制内正常工作 + }); + + it('should return non-React elements unchanged', () => { + const textNode = 'plain text'; + const numberNode = 42; + const nullNode = null; + + expect(sanitizeCloneElement(textNode)).toBe(textNode); + expect(sanitizeCloneElement(numberNode)).toBe(numberNode); + expect(sanitizeCloneElement(nullNode)).toBe(nullNode); + }); + + it('should use cache for performance', () => { + const props = { className: 'test', id: 'will-be-removed' }; + const node1 = React.createElement('div', props, 'content'); + const node2 = React.createElement('span', props, 'other content'); + + const result1 = sanitizeCloneElement(node1) as React.ReactElement; + const result2 = sanitizeCloneElement(node2) as React.ReactElement; + + // 虽然是不同的元素,但相同的 props 应该被缓存 + expect(result1.props.className).toBe('test'); + expect(result1.props.id).toBeUndefined(); + expect(result2.props.className).toBe('test'); + expect(result2.props.id).toBeUndefined(); + }); + + it('should return original node if no props need filtering', () => { + const node = React.createElement( + 'div', + { + className: 'test', + title: 'clean', + }, + 'content', + ); + + const result = sanitizeCloneElement(node); + + // 如果没有需要过滤的属性,应该返回原节点以提高性能 + expect(result).toBe(node); + }); + }); +}); diff --git a/src/utils/reactNodeUtil.tsx b/src/utils/reactNodeUtil.tsx new file mode 100644 index 000000000..6f9e898b3 --- /dev/null +++ b/src/utils/reactNodeUtil.tsx @@ -0,0 +1,76 @@ +import React, { cloneElement, isValidElement } from 'react'; + +// Props type definition +type Props = Record; + +// Cache processed props to avoid redundant calculations +const propsCache = new WeakMap(); + +// Set of properties to filter, using Set for better lookup performance +const FILTERED_PROPS = new Set(['id', 'ref', 'onFocus', 'onBlur', 'tabIndex']); + +const stripProps = (props: Props) => { + // Check cache first + if (propsCache.has(props)) { + const cachedProps = propsCache.get(props); + return cachedProps || props; + } + + const result: Props = {}; + let hasFilteredProps = false; + + for (const key in props) { + // Use Set for fast lookup, avoiding multiple || conditions + if (FILTERED_PROPS.has(key) || key.startsWith('data-')) { + hasFilteredProps = true; + continue; + } + result[key] = props[key]; + } + + // If no props need filtering, return original props directly + if (!hasFilteredProps) { + propsCache.set(props, props); + return props; + } + + // Cache the result + propsCache.set(props, result); + return result; +}; + +/** + * Recursively clone ReactNode and remove data-*, id, ref, onFocus, onBlur props + * to avoid potential issues with nested elements in table cells. + * @param node - React node to sanitize + * @param depth - Current recursion depth (for performance control) + * @param maxDepth - Maximum recursion depth to prevent stack overflow + */ +const sanitizeCloneElement = ( + node: React.ReactNode, + depth: number = 0, + maxDepth: number = 10, +): React.ReactNode => { + // Limit recursion depth to prevent performance issues and stack overflow + if (depth >= maxDepth || !isValidElement(node)) { + return node; + } + + const cleanedProps = stripProps(node.props); + + // If props haven't changed and no children, return original node directly + if (cleanedProps === node.props && !cleanedProps.children) { + return node; + } + + // Process children + if (cleanedProps.children) { + cleanedProps.children = React.Children.map(cleanedProps.children, (child: React.ReactNode) => + sanitizeCloneElement(child, depth + 1, maxDepth), + ); + } + + return cloneElement(node, cleanedProps); +}; + +export { sanitizeCloneElement };