diff --git a/src/Body/MeasureCell.tsx b/src/Body/MeasureCell.tsx index 275e26d94..be746e46f 100644 --- a/src/Body/MeasureCell.tsx +++ b/src/Body/MeasureCell.tsx @@ -1,30 +1,20 @@ import * as React from 'react'; import ResizeObserver from '@rc-component/resize-observer'; -import { useLayoutEffect } from '@rc-component/util'; export interface MeasureCellProps { columnKey: React.Key; - onColumnResize: (key: React.Key, width: number) => void; title?: React.ReactNode; } - +/** + * Measure cell renders the td used for width measurement and reports its + * size changes to the MeasureRow collection (#1507). + */ const MeasureCell: React.FC = props => { - const { columnKey, onColumnResize, title } = props; - - const cellRef = React.useRef(null); - - useLayoutEffect(() => { - if (cellRef.current) { - onColumnResize(columnKey, cellRef.current.offsetWidth); - } - }, []); + const { columnKey, title } = props; return ( - +
{title || '\xa0'}
diff --git a/src/Body/MeasureRow.tsx b/src/Body/MeasureRow.tsx index 264116a2c..707afbb6f 100644 --- a/src/Body/MeasureRow.tsx +++ b/src/Body/MeasureRow.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import ResizeObserver from '@rc-component/resize-observer'; -import MeasureCell from './MeasureCell'; -import { isVisible } from '@rc-component/util'; +import { isVisible, useEvent, useLayoutEffect } from '@rc-component/util'; import { useContext } from '@rc-component/context'; import TableContext from '../context/TableContext'; import type { ColumnType } from '../interface'; +import MeasureCell from './MeasureCell'; export interface MeasureRowProps { prefixCls: string; @@ -23,6 +23,37 @@ const MeasureRow: React.FC = ({ const { measureRowRender } = useContext(TableContext, ['measureRowRender']); + // Read every cell width in one synchronous pass. The reads have no writes + // between them, so the row costs one layout instead of one per cell, and + // every onColumnResize call batches into a single state update (#1507). + // It covers mount and column-set changes; later width changes (a column + // gaining width while the table keeps its width, auto layout reacting to + // fonts or content) are reported by the per-cell observers below, which a + // row-level observer can not see. + const measureColumns = useEvent(() => { + const row = ref.current; + if (!row || !isVisible(row)) { + return; + } + const cells = row.children; + columnsKey.forEach((columnKey, index) => { + const cell = cells[index]; + if (cell) { + onColumnResize(columnKey, (cell as HTMLTableCellElement).offsetWidth); + } + }); + }); + + // Serialize with separators: ['a_b', 'c'] and ['a', 'b_c'] are different + // column sets and must re-measure even though they join to the same string. + const columnsKeyStr = JSON.stringify(columnsKey); + + useLayoutEffect(() => { + measureColumns(); + // measureColumns is a stable useEvent handle; only the column set re-runs it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnsKeyStr]); + const measureRow = ( = ({ const titleForMeasure = React.isValidElement>(rawTitle) ? React.cloneElement(rawTitle, { ref: null }) : rawTitle; - return ( - - ); + return ; })} diff --git a/tests/FixedHeader.spec.jsx b/tests/FixedHeader.spec.jsx index 29add88f3..6dae051fd 100644 --- a/tests/FixedHeader.spec.jsx +++ b/tests/FixedHeader.spec.jsx @@ -141,6 +141,104 @@ describe('Table.FixedHeader', () => { vi.useRealTimers(); }); + it('re-measures when the column set changes', async () => { + const { container, rerender } = render( + , + ); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + const headerCols = () => + [...container.querySelectorAll('.rc-table-header table col')].map(col => col.style.width); + expect(headerCols()[0]).toEqual('100px'); + + // A wider column set must be measured again without waiting for a resize. + rerender( +
, + ); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + const widths = headerCols(); + expect(widths).toHaveLength(4); // 3 measured columns + the scroll gutter + expect(widths[0]).toEqual('100px'); + expect(widths[1]).toEqual('100px'); + expect(widths[2]).toEqual('100px'); + + vi.useRealTimers(); + }); + + it('re-measures when new column keys collide in a joined identifier', async () => { + const { container, rerender } = render( +
, + ); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + // ['a_b', 'c'] -> ['a', 'b_c'] keep the same joined form 'a_b_c'. + rerender( +
, + ); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + const widths = [...container.querySelectorAll('.rc-table-header table col')].map( + col => col.style.width, + ); + expect(widths[0]).toEqual('100px'); + expect(widths[1]).toEqual('100px'); + + vi.useRealTimers(); + }); + + it('reports a cell resize when the row keeps its size', async () => { + const { container } = render( +
, + ); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + const headerCols = () => + [...container.querySelectorAll('.rc-table-header table col')].map(col => col.style.width); + expect(headerCols()[0]).toEqual('100px'); + + // Width moves between columns (fixed layout, constant table width): + // only cells change size, the measure row keeps its own size. + measureWidth = 80; + const cells = container.querySelectorAll('.rc-table-measure-row td'); + await triggerResize(cells[0]); + + expect(headerCols()[0]).toEqual('80px'); + expect(headerCols()[1]).toEqual('100px'); + + vi.useRealTimers(); + }); + it('do not mask as fixed in nested table parent cell', async () => { const columns = [ {