From e463d060a940f0f68ac98c62b95f4df9d61e3510 Mon Sep 17 00:00:00 2001 From: jiangrong-devops <18707886349@163.com> Date: Wed, 13 May 2026 17:55:24 +0800 Subject: [PATCH 1/4] fix(table): fix row hover with expanded offset rowSpan --- src/Body/BodyRow.tsx | 6 +++- src/Cell/index.tsx | 8 +++-- src/VirtualTable/VirtualCell.tsx | 3 +- tests/ExpandedOffset.spec.tsx | 2 +- tests/Hover.spec.tsx | 60 ++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/Body/BodyRow.tsx b/src/Body/BodyRow.tsx index aae9f4c7b..7a091a1bc 100644 --- a/src/Body/BodyRow.tsx +++ b/src/Body/BodyRow.tsx @@ -85,6 +85,7 @@ export function getCellProps( } const additionalCellProps = column.onCell?.(record, index) || {}; + let hoverRowSpan: number | undefined; // Expandable row has offset if (expandedRowOffset) { @@ -93,6 +94,7 @@ export function getCellProps( // For expandable row with rowSpan, // We should increase the rowSpan if the row is expanded if (expandable && rowSpan && colIndex < expandedRowOffset) { + hoverRowSpan = rowSpan; let currentRowSpan = rowSpan; for (let i = index; i < index + rowSpan; i += 1) { @@ -110,6 +112,7 @@ export function getCellProps( fixedInfo, appendCellNode, additionalCellProps: additionalCellProps, + hoverRowSpan, }; } @@ -187,7 +190,7 @@ const BodyRow = ( {flattenColumns.map((column: ColumnType, colIndex) => { const { render, dataIndex, className: columnClassName } = column; - const { key, fixedInfo, appendCellNode, additionalCellProps } = getCellProps( + const { key, fixedInfo, appendCellNode, additionalCellProps, hoverRowSpan } = getCellProps( rowInfo, column, colIndex, @@ -216,6 +219,7 @@ const BodyRow = ( {...fixedInfo} appendNode={appendCellNode} additionalProps={additionalCellProps} + hoverRowSpan={hoverRowSpan} /> ); })} diff --git a/src/Cell/index.tsx b/src/Cell/index.tsx index 3d07772b5..b557dab69 100644 --- a/src/Cell/index.tsx +++ b/src/Cell/index.tsx @@ -52,6 +52,8 @@ export interface CellProps { /** @private Used for `expandable` with nest tree */ appendNode?: React.ReactNode; additionalProps?: React.TdHTMLAttributes; + /** @private Keep hover range independent from layout rowSpan patched by expanded row */ + hoverRowSpan?: number; rowType?: 'header' | 'body' | 'footer'; @@ -123,6 +125,7 @@ const Cell = (props: CellProps) => { // Private appendNode, additionalProps = {}, + hoverRowSpan, isSticky, } = props; @@ -183,13 +186,14 @@ const Cell = (props: CellProps) => { // ================ RowSpan & ColSpan ================= const mergedColSpan = legacyCellProps?.colSpan ?? additionalProps.colSpan ?? colSpan ?? 1; const mergedRowSpan = legacyCellProps?.rowSpan ?? additionalProps.rowSpan ?? rowSpan ?? 1; + const mergedHoverRowSpan = hoverRowSpan ?? mergedRowSpan; // ====================== Hover ======================= - const [hovering, onHover] = useHoverState(index, mergedRowSpan); + const [hovering, onHover] = useHoverState(index, mergedHoverRowSpan); const onMouseEnter: React.MouseEventHandler = useEvent(event => { if (record) { - onHover(index, index + mergedRowSpan - 1); + onHover(index, index + mergedHoverRowSpan - 1); } additionalProps?.onMouseEnter?.(event); diff --git a/src/VirtualTable/VirtualCell.tsx b/src/VirtualTable/VirtualCell.tsx index ebcfff4ad..3fe2bb180 100644 --- a/src/VirtualTable/VirtualCell.tsx +++ b/src/VirtualTable/VirtualCell.tsx @@ -57,7 +57,7 @@ const VirtualCell = (props: VirtualCellProps) => { const { columnsOffset } = useContext(GridContext, ['columnsOffset']); // TODO: support `expandableRowOffset` - const { key, fixedInfo, appendCellNode, additionalCellProps } = getCellProps( + const { key, fixedInfo, appendCellNode, additionalCellProps, hoverRowSpan } = getCellProps( rowInfo, column, colIndex, @@ -128,6 +128,7 @@ const VirtualCell = (props: VirtualCellProps) => { shouldCellUpdate={column.shouldCellUpdate} {...fixedInfo} appendNode={appendCellNode} + hoverRowSpan={hoverRowSpan} additionalProps={{ ...additionalCellProps, style: mergedStyle, diff --git a/tests/ExpandedOffset.spec.tsx b/tests/ExpandedOffset.spec.tsx index 12d345369..5f20106f9 100644 --- a/tests/ExpandedOffset.spec.tsx +++ b/tests/ExpandedOffset.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; import { render, act } from '@testing-library/react'; import { _rs } from '@rc-component/resize-observer'; import Table, { type ColumnsType } from '../src'; diff --git a/tests/Hover.spec.tsx b/tests/Hover.spec.tsx index 813f09a6b..341440b1a 100644 --- a/tests/Hover.spec.tsx +++ b/tests/Hover.spec.tsx @@ -6,6 +6,7 @@ import Table from '../src'; import type { TableProps } from '../src/Table'; describe('Table.Hover', () => { + const hoverClassName = 'rc-table-cell-row-hover'; const data = [ { key: 'key0', name: 'Lucy' }, { key: 'key1', name: 'Jack' }, @@ -128,6 +129,65 @@ describe('Table.Hover', () => { expect(container.querySelector('.rc-table-cell-row-hover')).toBeFalsy(); }); + it('does not let expanded row offset rowSpan affect hover range', () => { + const { container } = render( + { + if (index === 0) { + return { rowSpan: 2 }; + } + if (index === 1) { + return { rowSpan: 0 }; + } + return {}; + }, + }, + Table.EXPAND_COLUMN, + { + dataIndex: 'name', + }, + ]} + data={[ + { key: 'a', group: 'Group 1', name: 'Alpha' }, + { key: 'b', group: 'Group 1', name: 'Beta' }, + { key: 'c', group: 'Group 2', name: 'Gamma' }, + ]} + expandable={{ + expandedRowOffset: 1, + expandedRowKeys: ['a'], + expandedRowRender: record => expanded {record.key}, + }} + />, + ); + + const getCell = (text: string) => { + const cell = Array.from(container.querySelectorAll('tbody td')).find( + cell => cell.textContent === text, + ); + expect(cell).toBeTruthy(); + return cell!; + }; + + const groupCell = getCell('Group 1'); + const betaCell = getCell('Beta'); + const gammaCell = getCell('Gamma'); + + expect(groupCell.getAttribute('rowspan')).toBe('3'); + + fireEvent.mouseEnter(groupCell); + expect(groupCell.classList.contains(hoverClassName)).toBe(true); + expect(betaCell.classList.contains(hoverClassName)).toBe(true); + expect(gammaCell.classList.contains(hoverClassName)).toBe(false); + + fireEvent.mouseEnter(gammaCell); + expect(groupCell.classList.contains(hoverClassName)).toBe(false); + expect(gammaCell.classList.contains(hoverClassName)).toBe(true); + }); + describe('perf', () => { it('legacy mode should render every time', () => { let renderTimes = 0; From 2b7deac96513d3ebd42413c84b27782b1cf3ad2c Mon Sep 17 00:00:00 2001 From: jiangrong-devops <18707886349@163.com> Date: Fri, 31 Jul 2026 20:21:08 +0800 Subject: [PATCH 2/4] fix(table): preserve hover rowSpan with expanded offset Respect legacy render rowSpan when calculating hover ranges and avoid mutating objects returned by onCell. Add regression coverage for legacy cells, stable props, StrictMode, and expanded row rerenders. --- src/Body/BodyRow.tsx | 2 +- src/Cell/index.tsx | 2 +- tests/Hover.spec.tsx | 100 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/Body/BodyRow.tsx b/src/Body/BodyRow.tsx index 7a091a1bc..d64c01863 100644 --- a/src/Body/BodyRow.tsx +++ b/src/Body/BodyRow.tsx @@ -84,7 +84,7 @@ export function getCellProps( ); } - const additionalCellProps = column.onCell?.(record, index) || {}; + const additionalCellProps = { ...(column.onCell?.(record, index) || {}) }; let hoverRowSpan: number | undefined; // Expandable row has offset diff --git a/src/Cell/index.tsx b/src/Cell/index.tsx index b557dab69..659e63970 100644 --- a/src/Cell/index.tsx +++ b/src/Cell/index.tsx @@ -186,7 +186,7 @@ const Cell = (props: CellProps) => { // ================ RowSpan & ColSpan ================= const mergedColSpan = legacyCellProps?.colSpan ?? additionalProps.colSpan ?? colSpan ?? 1; const mergedRowSpan = legacyCellProps?.rowSpan ?? additionalProps.rowSpan ?? rowSpan ?? 1; - const mergedHoverRowSpan = hoverRowSpan ?? mergedRowSpan; + const mergedHoverRowSpan = legacyCellProps?.rowSpan ?? hoverRowSpan ?? mergedRowSpan; // ====================== Hover ======================= const [hovering, onHover] = useHoverState(index, mergedHoverRowSpan); diff --git a/tests/Hover.spec.tsx b/tests/Hover.spec.tsx index 305c0a6d3..db5894587 100644 --- a/tests/Hover.spec.tsx +++ b/tests/Hover.spec.tsx @@ -187,6 +187,106 @@ describe('Table.Hover', () => { expect(gammaCell.classList.contains(hoverClassName)).toBe(true); }); + it('keeps legacy render rowSpan priority for hover range', () => { + const { container } = render( +
({ + children: value, + props: { rowSpan: index === 0 ? 2 : 0 }, + }), + }, + Table.EXPAND_COLUMN, + { + dataIndex: 'name', + }, + ]} + data={[ + { key: 'a', group: 'Group 1', name: 'Alpha' }, + { key: 'b', group: 'Group 1', name: 'Beta' }, + { key: 'c', group: 'Group 2', name: 'Gamma' }, + ]} + expandable={{ + expandedRowOffset: 1, + expandedRowRender: record => expanded {record.key}, + }} + />, + ); + + const getCell = (text: string) => { + const cell = Array.from(container.querySelectorAll('tbody td')).find( + item => item.textContent === text, + ); + expect(cell).toBeTruthy(); + return cell!; + }; + + const groupCell = getCell('Group 1'); + const alphaCell = getCell('Alpha'); + const betaCell = getCell('Beta'); + const gammaCell = getCell('Gamma'); + + expect(groupCell.getAttribute('rowspan')).toBe('2'); + + fireEvent.mouseEnter(groupCell); + expect(alphaCell.classList.contains(hoverClassName)).toBe(true); + expect(betaCell.classList.contains(hoverClassName)).toBe(true); + expect(gammaCell.classList.contains(hoverClassName)).toBe(false); + }); + + it('does not mutate stable onCell props across expanded row renders', () => { + const rowSpanProps = [{ rowSpan: 2 }, { rowSpan: 0 }, {}]; + const dataSource = [ + { key: 'a', group: 'Group 1', name: 'Alpha' }, + { key: 'b', group: 'Group 1', name: 'Beta' }, + { key: 'c', group: 'Group 2', name: 'Gamma' }, + ]; + + const createTableWithExpandedKeys = (expandedRowKeys: React.Key[]) => ( + +
rowSpanProps[index], + }, + Table.EXPAND_COLUMN, + { + dataIndex: 'name', + }, + ]} + data={dataSource} + expandable={{ + expandedRowOffset: 1, + expandedRowKeys, + expandedRowRender: record => expanded {record.key}, + }} + /> + + ); + + const { container, rerender } = render(createTableWithExpandedKeys([])); + const getGroupCell = () => + Array.from(container.querySelectorAll('tbody td')).find( + cell => cell.textContent === 'Group 1', + )!; + + expect(rowSpanProps[0].rowSpan).toBe(2); + expect(getGroupCell().getAttribute('rowspan')).toBe('2'); + + rerender(createTableWithExpandedKeys(['a'])); + expect(rowSpanProps[0].rowSpan).toBe(2); + expect(getGroupCell().getAttribute('rowspan')).toBe('3'); + + rerender(createTableWithExpandedKeys([])); + expect(rowSpanProps[0].rowSpan).toBe(2); + expect(getGroupCell().getAttribute('rowspan')).toBe('2'); + }); + describe('perf', () => { it('legacy mode should render every time', () => { let renderTimes = 0; From 03329fcdc666b526c57232f87b7169a18537be77 Mon Sep 17 00:00:00 2001 From: mwbdtth <448847482@qq.com> Date: Sat, 1 Aug 2026 11:57:51 +0800 Subject: [PATCH 3/4] Update tests/Hover.spec.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/Hover.spec.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Hover.spec.tsx b/tests/Hover.spec.tsx index db5894587..8a47175eb 100644 --- a/tests/Hover.spec.tsx +++ b/tests/Hover.spec.tsx @@ -211,6 +211,7 @@ describe('Table.Hover', () => { ]} expandable={{ expandedRowOffset: 1, + defaultExpandAllRows: true, expandedRowRender: record => expanded {record.key}, }} />, From a5fb80c5d63d319446b3e6708d4a525908acf0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 3 Aug 2026 15:06:39 +0800 Subject: [PATCH 4/4] refactor(table): clarify origin rowSpan --- src/Body/BodyRow.tsx | 12 ++++++------ src/Cell/index.tsx | 8 ++++---- src/VirtualTable/VirtualCell.tsx | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Body/BodyRow.tsx b/src/Body/BodyRow.tsx index 1d053dd6d..64b8a096a 100644 --- a/src/Body/BodyRow.tsx +++ b/src/Body/BodyRow.tsx @@ -84,8 +84,8 @@ export function getCellProps( ); } - const additionalCellProps = { ...(column.onCell?.(record, index) || {}) }; - let hoverRowSpan: number | undefined; + const additionalCellProps = { ...column.onCell?.(record, index) }; + let originRowSpan: number | undefined; // Expandable row has offset if (expandedRowOffset) { @@ -94,7 +94,7 @@ export function getCellProps( // For expandable row with rowSpan, // We should increase the rowSpan if the row is expanded if (expandable && rowSpan && colIndex < expandedRowOffset) { - hoverRowSpan = rowSpan; + originRowSpan = rowSpan; let currentRowSpan = rowSpan; for (let i = index; i < index + rowSpan; i += 1) { @@ -112,7 +112,7 @@ export function getCellProps( fixedInfo, appendCellNode, additionalCellProps: additionalCellProps, - hoverRowSpan, + originRowSpan, }; } @@ -191,7 +191,7 @@ const BodyRow = ( {flattenColumns.map((column: ColumnType, colIndex) => { const { render, dataIndex, className: columnClassName } = column; - const { key, fixedInfo, appendCellNode, additionalCellProps, hoverRowSpan } = getCellProps( + const { key, fixedInfo, appendCellNode, additionalCellProps, originRowSpan } = getCellProps( rowInfo, column, colIndex, @@ -220,7 +220,7 @@ const BodyRow = ( {...fixedInfo} appendNode={appendCellNode} additionalProps={additionalCellProps} - hoverRowSpan={hoverRowSpan} + originRowSpan={originRowSpan} /> ); })} diff --git a/src/Cell/index.tsx b/src/Cell/index.tsx index 659e63970..6276e9f7a 100644 --- a/src/Cell/index.tsx +++ b/src/Cell/index.tsx @@ -52,8 +52,8 @@ export interface CellProps { /** @private Used for `expandable` with nest tree */ appendNode?: React.ReactNode; additionalProps?: React.TdHTMLAttributes; - /** @private Keep hover range independent from layout rowSpan patched by expanded row */ - hoverRowSpan?: number; + /** @private Original rowSpan before patching it for expanded rows */ + originRowSpan?: number; rowType?: 'header' | 'body' | 'footer'; @@ -125,7 +125,7 @@ const Cell = (props: CellProps) => { // Private appendNode, additionalProps = {}, - hoverRowSpan, + originRowSpan, isSticky, } = props; @@ -186,7 +186,7 @@ const Cell = (props: CellProps) => { // ================ RowSpan & ColSpan ================= const mergedColSpan = legacyCellProps?.colSpan ?? additionalProps.colSpan ?? colSpan ?? 1; const mergedRowSpan = legacyCellProps?.rowSpan ?? additionalProps.rowSpan ?? rowSpan ?? 1; - const mergedHoverRowSpan = legacyCellProps?.rowSpan ?? hoverRowSpan ?? mergedRowSpan; + const mergedHoverRowSpan = legacyCellProps?.rowSpan ?? originRowSpan ?? mergedRowSpan; // ====================== Hover ======================= const [hovering, onHover] = useHoverState(index, mergedHoverRowSpan); diff --git a/src/VirtualTable/VirtualCell.tsx b/src/VirtualTable/VirtualCell.tsx index 3fe2bb180..339c6c0d0 100644 --- a/src/VirtualTable/VirtualCell.tsx +++ b/src/VirtualTable/VirtualCell.tsx @@ -57,7 +57,7 @@ const VirtualCell = (props: VirtualCellProps) => { const { columnsOffset } = useContext(GridContext, ['columnsOffset']); // TODO: support `expandableRowOffset` - const { key, fixedInfo, appendCellNode, additionalCellProps, hoverRowSpan } = getCellProps( + const { key, fixedInfo, appendCellNode, additionalCellProps, originRowSpan } = getCellProps( rowInfo, column, colIndex, @@ -128,7 +128,7 @@ const VirtualCell = (props: VirtualCellProps) => { shouldCellUpdate={column.shouldCellUpdate} {...fixedInfo} appendNode={appendCellNode} - hoverRowSpan={hoverRowSpan} + originRowSpan={originRowSpan} additionalProps={{ ...additionalCellProps, style: mergedStyle,