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 aa237c93c..a1944feb6 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,49 @@ 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] as HTMLTableCellElement; + if (cell) { + // Same width the per-cell observer reports: it hands over the fractional + // getBoundingClientRect width whenever offsetWidth is that width rounded. + // A cell that keeps its size never notifies again, so an integer read here + // would stick, and the rounding error of each column adds up against the + // header. + const { width } = cell.getBoundingClientRect(); + const { offsetWidth } = cell; + onColumnResize(columnKey, offsetWidth === Math.round(width) ? width : offsetWidth); + } + }); + }); + + // Sorted, so moving the same columns around does not re-measure: the cells keep + // their keys, every width change that follows a reorder reaches the per-cell + // observers. Serialized with the type and with separators because ['a_b', 'c'] + // and ['a', 'b_c'] are different column sets that join to the same string, and + // React.Key also allows bigint, which JSON.stringify refuses to take. + const columnsKeyStr = JSON.stringify( + columnsKey.map(columnKey => `${typeof columnKey}:${columnKey}`).sort(), + ); + + useLayoutEffect(() => { + measureColumns(); + // measureColumns is a stable useEvent handle; only the column set re-runs it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnsKeyStr]); + // inert also covers a title that sets visibility: visible on itself. It is set on the // element because React 18 drops a boolean `inert` prop and React 19 reads `inert=""` // as false. @@ -56,14 +99,7 @@ const MeasureRow: React.FC = ({ 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 2137bd218..fc0b3fc94 100644 --- a/tests/FixedHeader.spec.jsx +++ b/tests/FixedHeader.spec.jsx @@ -141,6 +141,200 @@ 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('keeps the measured sub-pixel width the observer would report', async () => { + // The per-cell observer reports getBoundingClientRect().width whenever + // offsetWidth is that width rounded, so the batched pass has to match it. + const rectSpy = spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ width: 100.4, height: 10 }), + }); + + const { container } = render( +
, + ); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + expect(container.querySelector('.rc-table-header table col').style.width).toEqual('100.4px'); + + rectSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('measures columns keyed by bigint', async () => { + const { container } = render( +
, + ); + + 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('keeps sub-pixel widths when 40 columns are reordered', async () => { + // Fixed layout, 2025px table, 40 columns of 50.625px: offsetWidth rounds each + // one to 51, so an integer pass drifts the header to 2040px against a 2025px + // body. The observer reports the fractional width and a reorder has to keep it. + measureWidth = 51; + const rectSpy = spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ width: 50.625, height: 10 }), + }); + const columns = Array.from({ length: 40 }, (_, index) => ({ + key: `c${index}`, + dataIndex: `c${index}`, + })); + const data = [Object.fromEntries([['key', 1], ...columns.map(col => [col.key, col.key])])]; + + const { container, rerender } = render( +
, + ); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + const measuredWidths = () => + [...container.querySelectorAll('.rc-table-header table col')] + .slice(0, columns.length) + .map(col => parseFloat(col.style.width)); + + rerender(
); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + const widths = measuredWidths(); + expect(widths).toHaveLength(40); + expect(widths.every(width => width === 50.625)).toBe(true); + expect(widths.reduce((total, width) => total + width, 0)).toBeCloseTo(2025, 5); + + rectSpy.mockRestore(); + vi.useRealTimers(); + }); + it('do not mask as fixed in nested table parent cell', async () => { const columns = [ {