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
32 changes: 29 additions & 3 deletions src/Body/BodyRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { ColumnType, CustomizeComponent } from '../interface';
import ExpandedRow from './ExpandedRow';
import { computedExpandedClassName } from '../utils/expandUtil';
import type { TableProps } from '..';
import useStickyOffsets from '../hooks/useStickyOffsets';
import { getCellFixedInfo } from '../utils/fixUtil';

export interface BodyRowProps<RecordType> {
record: RecordType;
Expand Down Expand Up @@ -43,12 +45,16 @@ export function getCellProps<RecordType>(
index: number,
rowKeys: React.Key[] = [],
expandedRowOffset = 0,
rowStickyOffsets?: ReturnType<typeof useStickyOffsets>,
hasColSpanZero?: boolean,
cachedCellProps?: Record<string, any>,
) {
const {
record,
prefixCls,
columnsKey,
fixedInfoList,
flattenColumns,
expandIconColumnIndex,
nestExpandable,
indentSize,
Expand All @@ -61,9 +67,11 @@ export function getCellProps<RecordType>(
} = rowInfo;

const key = columnsKey[colIndex];
const fixedInfo = fixedInfoList[colIndex];
let fixedInfo = fixedInfoList[colIndex];

// ============= Used for nest expandable =============
if (column.fixed && hasColSpanZero && rowStickyOffsets) {
fixedInfo = getCellFixedInfo(colIndex, colIndex, flattenColumns, rowStickyOffsets);
}
let appendCellNode: React.ReactNode;
if (colIndex === (expandIconColumnIndex || 0) && nestExpandable) {
appendCellNode = (
Expand All @@ -83,7 +91,7 @@ export function getCellProps<RecordType>(
);
}

const additionalCellProps = column.onCell?.(record, index) || {};
const additionalCellProps = { ...(cachedCellProps || column.onCell?.(record, index) || {}) };

// Expandable row has offset
if (expandedRowOffset) {
Expand Down Expand Up @@ -143,6 +151,7 @@ function BodyRow<RecordType extends { children?: readonly RecordType[] }>(
const {
prefixCls,
flattenColumns,
colWidths,
expandedRowClassName,
expandedRowRender,
rowProps,
Expand All @@ -152,6 +161,20 @@ function BodyRow<RecordType extends { children?: readonly RecordType[] }>(
rowSupportExpand,
} = rowInfo;

const cellPropsCache = React.useMemo(() => {
return flattenColumns.map(col => col.onCell?.(record, index) || {});
Comment on lines +164 to +165
Copy link
Preview

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cellPropsCache is recalculated on every render when record or index changes. For large tables with many columns, this could be expensive. Consider memoizing individual cell props or using a more granular dependency array.

Suggested change
const cellPropsCache = React.useMemo(() => {
return flattenColumns.map(col => col.onCell?.(record, index) || {});
return flattenColumns.map((col, colIdx) =>
React.useMemo(
() => col.onCell?.(record, index) || {},
[col, col.onCell, record, index]
)
);

Copilot uses AI. Check for mistakes.

}, [flattenColumns, record, index]);

const hasColSpanZero = React.useMemo(() => {
return cellPropsCache.some(cellProps => (cellProps.colSpan ?? 1) === 0);
}, [cellPropsCache]);

const rowStickyOffsets = useStickyOffsets(
colWidths,
flattenColumns,
hasColSpanZero ? { record, rowIndex: index } : undefined,
);
Comment on lines +172 to +176
Copy link
Preview

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rowStickyOffsets hook is called on every render even when hasColSpanZero is false. Consider conditionally calling this hook only when needed to avoid unnecessary calculations.

Suggested change
const rowStickyOffsets = useStickyOffsets(
colWidths,
flattenColumns,
hasColSpanZero ? { record, rowIndex: index } : undefined,
);
const rowStickyOffsets = hasColSpanZero
? useStickyOffsets(
colWidths,
flattenColumns,
{ record, rowIndex: index },
)
: undefined;

Copilot uses AI. Check for mistakes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

条件性调用 Hook违反规则


// Force render expand row if expanded before
const expandedRef = React.useRef(false);
expandedRef.current ||= expanded;
Expand Down Expand Up @@ -196,6 +219,9 @@ function BodyRow<RecordType extends { children?: readonly RecordType[] }>(
index,
rowKeys,
expandedRowInfo?.offset,
rowStickyOffsets,
hasColSpanZero,
cellPropsCache[colIndex],
);

return (
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useRowInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default function useRowInfo<RecordType>(
| 'prefixCls'
| 'fixedInfoList'
| 'flattenColumns'
| 'colWidths'
| 'expandableType'
| 'expandRowByClick'
| 'onTriggerExpand'
Expand All @@ -41,6 +42,7 @@ export default function useRowInfo<RecordType>(
'prefixCls',
'fixedInfoList',
'flattenColumns',
'colWidths',
'expandableType',
'expandRowByClick',
'onTriggerExpand',
Expand Down
16 changes: 14 additions & 2 deletions src/hooks/useStickyOffsets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import type { ColumnType, StickyOffsets } from '../interface';

/**
* Get sticky column offset width
* @param colWidths - Column widths array
* @param flattenColumns - Flattened columns
* @param rowContext - Optional row context for dynamic colSpan calculation
*/
function useStickyOffsets<RecordType>(
colWidths: number[],
flattenColumns: readonly ColumnType<RecordType>[],
rowContext?: { record: RecordType; rowIndex: number },
) {
const stickyOffsets: StickyOffsets = useMemo(() => {
const columnCount = flattenColumns.length;
Expand All @@ -16,9 +20,17 @@ function useStickyOffsets<RecordType>(
let total = 0;

for (let i = startIndex; i !== endIndex; i += offset) {
const column = flattenColumns[i];

offsets.push(total);

if (flattenColumns[i].fixed) {
let colSpan = 1;
if (rowContext) {
const cellProps = column.onCell?.(rowContext.record, rowContext.rowIndex) || {};
colSpan = cellProps.colSpan ?? 1;
}

if (column.fixed && colSpan !== 0) {
total += colWidths[i] || 0;
}
}
Expand All @@ -34,7 +46,7 @@ function useStickyOffsets<RecordType>(
end: endOffsets,
widths: colWidths,
};
}, [colWidths, flattenColumns]);
}, [colWidths, flattenColumns, rowContext]);

return stickyOffsets;
}
Expand Down
135 changes: 135 additions & 0 deletions tests/FixedColumn.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,139 @@ describe('Table.FixedColumn', () => {
expect(container.querySelector('.rc-table')).toHaveClass('rc-table-fix-start-shadow-show');
expect(container.querySelector('.rc-table')).toHaveClass('rc-table-fix-end-shadow-show');
});

describe('colSpan=0 with fixed columns regression test', () => {
interface TestDataType {
key: string;
col0: string;
col1: string;
col2: string;
}

const testColumns: ColumnsType<TestDataType> = [
{
title: 'Column 0',
dataIndex: 'col0',
key: 'col0',
width: 100,
fixed: 'left',
onCell: (record, index) => {
if (index === 1) {
return { colSpan: 0 };
}
return {};
},
},
{
title: 'Column 1',
dataIndex: 'col1',
key: 'col1',
width: 120,
fixed: 'left',
onCell: (record, index) => {
if (index === 1) {
return { colSpan: 2 };
}
return {};
},
},
{
title: 'Column 2',
dataIndex: 'col2',
key: 'col2',
width: 150,
},
];

const testData: TestDataType[] = [
{ key: '0', col0: 'Row0-Col0', col1: 'Row0-Col1', col2: 'Row0-Col2' },
{ key: '1', col0: 'Row1-Col0', col1: 'Row1-Merged', col2: 'Row1-Col2' },
{ key: '2', col0: 'Row2-Col0', col1: 'Row2-Col1', col2: 'Row2-Col2' },
];

it('should calculate correct sticky offsets when colSpan=0 exists', async () => {
const { container } = render(
<Table columns={testColumns} data={testData} scroll={{ x: 500 }} />,
);

await triggerResize(container.querySelector<HTMLElement>('.rc-table'));

act(() => {
const coll = container.querySelector('.rc-table-resize-collection');
if (coll) {
triggerResize(coll as HTMLElement);
}
});

await act(async () => {
vi.runAllTimers();
await Promise.resolve();
});

const rows = container.querySelectorAll('.rc-table-tbody .rc-table-row');
expect(rows).toHaveLength(3);

const secondRow = rows[1];
const cells = secondRow.querySelectorAll('.rc-table-cell');
expect(cells).toHaveLength(2);

const mergedCell = cells[0];
expect(mergedCell).toHaveAttribute('colSpan', '2');

expect(mergedCell.textContent).toContain('Row1-Merged');
const hasFixedLeftClass = mergedCell.classList.contains('rc-table-cell-fix-left');

if (hasFixedLeftClass) {
const cellStyle = window.getComputedStyle(mergedCell);
expect(cellStyle.left).toBe('0px');
}
Comment on lines +419 to +424
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

让断言更强:固定列类名应为必然条件,且使用逻辑方向属性以避免环境差异

当前以“如果包含 rc-table-cell-fix-left 再断言 left”为条件,可能在类名缺失时静默通过,掩盖回归。并且使用 left 在开启逻辑方向属性(inset-inline-start)的实现或 RTL 下可能不稳定。建议:

  • 显式断言单元格具有固定左类名;
  • 使用 computedStyle 同时兼容 leftinset-inline-start

[建议变更如下]

-      const hasFixedLeftClass = mergedCell.classList.contains('rc-table-cell-fix-left');
-
-      if (hasFixedLeftClass) {
-        const cellStyle = window.getComputedStyle(mergedCell);
-        expect(cellStyle.left).toBe('0px');
-      }
+      // 固定左列应当存在
+      expect(mergedCell).toHaveClass('rc-table-cell-fix-left');
+      // 兼容传统属性与逻辑方向属性
+      const cellStyle = window.getComputedStyle(mergedCell);
+      const left = cellStyle.left;
+      const insetInlineStart = cellStyle.getPropertyValue('inset-inline-start');
+      expect(left === '0px' || insetInlineStart === '0px').toBeTruthy();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasFixedLeftClass = mergedCell.classList.contains('rc-table-cell-fix-left');
if (hasFixedLeftClass) {
const cellStyle = window.getComputedStyle(mergedCell);
expect(cellStyle.left).toBe('0px');
}
// 固定左列应当存在
expect(mergedCell).toHaveClass('rc-table-cell-fix-left');
// 兼容传统属性与逻辑方向属性
const cellStyle = window.getComputedStyle(mergedCell);
const left = cellStyle.left;
const insetInlineStart = cellStyle.getPropertyValue('inset-inline-start');
expect(left === '0px' || insetInlineStart === '0px').toBeTruthy();
🤖 Prompt for AI Agents
In tests/FixedColumn.spec.tsx around lines 419 to 424, the test currently
conditionally asserts the computed left when the cell has the
'rc-table-cell-fix-left' class which can silently pass if the class is missing
and uses the physical 'left' property which is unstable in RTL or when logical
inset-inline-start is used; change this to explicitly assert that
mergedCell.classList contains 'rc-table-cell-fix-left' (fail the test if
missing), then read computedStyle and assert that either computedStyle.left ===
'0px' or computedStyle.getPropertyValue('inset-inline-start') === '0px' (i.e.,
require at least one to be '0px') so the test is both strict about the class
presence and robust across logical/physical CSS directions.

});

it('should work correctly with expandable rows', async () => {
const expandableTestData = testData.map(item => ({
...item,
children:
item.key === '1'
? [{ key: '1-0', col0: 'Child-Col0', col1: 'Child-Col1', col2: 'Child-Col2' }]
: undefined,
}));

const { container } = render(
<Table
columns={testColumns}
data={expandableTestData}
scroll={{ x: 500 }}
expandable={{
expandedRowKeys: ['1'],
expandRowByClick: true,
}}
/>,
);

await triggerResize(container.querySelector<HTMLElement>('.rc-table'));

act(() => {
const coll = container.querySelector('.rc-table-resize-collection');
if (coll) {
triggerResize(coll as HTMLElement);
}
});

await act(async () => {
vi.runAllTimers();
await Promise.resolve();
});

const allRows = container.querySelectorAll('.rc-table-tbody .rc-table-row');
expect(allRows.length).toBeGreaterThan(3); // 包含展开的子行

const parentRow = allRows[1];
const parentCells = parentRow.querySelectorAll('.rc-table-cell');
expect(parentCells).toHaveLength(2);

const childRow = allRows[2];
const childCells = childRow.querySelectorAll('.rc-table-cell');
expect(childCells).toHaveLength(3);
});
});
});