From 1da684a39aa98f4d22005e31e796c4cb9ad1fc12 Mon Sep 17 00:00:00 2001 From: rubenmarcus Date: Wed, 30 Sep 2026 08:07:49 +0100 Subject: [PATCH 1/2] perf: measure columns in one batched pass with a row-level observer (#1507) --- src/Body/MeasureCell.tsx | 30 ++++++-------------- src/Body/MeasureRow.tsx | 57 +++++++++++++++++++++++--------------- tests/FixedHeader.spec.jsx | 40 ++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 44 deletions(-) diff --git a/src/Body/MeasureCell.tsx b/src/Body/MeasureCell.tsx index 275e26d94..923c0dee6 100644 --- a/src/Body/MeasureCell.tsx +++ b/src/Body/MeasureCell.tsx @@ -1,33 +1,19 @@ 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 only renders the td used for width measurement. + * Width reading is batched by MeasureRow (#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 { title } = props; return ( - - -
{title || '\xa0'}
- -
+ +
{title || '\xa0'}
+ ); }; diff --git a/src/Body/MeasureRow.tsx b/src/Body/MeasureRow.tsx index 264116a2c..d8c9c86b8 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,34 +23,47 @@ const MeasureRow: React.FC = ({ const { measureRowRender } = useContext(TableContext, ['measureRowRender']); + // Read every cell width in one synchronous pass. rc-table renders the + // measure row only with a fixed table layout (fixHeader, horizontal scroll + // or sticky), where a column width can only change when the row itself + // resizes or the column set changes, so one row-level observer plus a + // re-measure on column changes observes everything a per-cell + // ResizeObserver would, without one forced layout read per column (#1507). + 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); + } + }); + }); + + const columnsKeyStr = columnsKey.join('_'); + + 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 = ( - - { - if (isVisible(ref.current)) { - infoList.forEach(({ data: columnKey, size }) => { - onColumnResize(columnKey, size.offsetWidth); - }); - } - }} - > + + {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 ( - - ); + return ; })} - - + + ); return typeof measureRowRender === 'function' ? measureRowRender(measureRow) : measureRow; diff --git a/tests/FixedHeader.spec.jsx b/tests/FixedHeader.spec.jsx index 29add88f3..b6284a07f 100644 --- a/tests/FixedHeader.spec.jsx +++ b/tests/FixedHeader.spec.jsx @@ -141,6 +141,46 @@ 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('do not mask as fixed in nested table parent cell', async () => { const columns = [ { From 5e182a76d3e0b2cac9cca8946f95578695ecf670 Mon Sep 17 00:00:00 2001 From: rubenmarcus Date: Thu, 1 Oct 2026 21:35:42 +0100 Subject: [PATCH 2/2] fix: restore per-cell resize reports and unique measure key --- src/Body/MeasureCell.tsx | 16 +++++++---- src/Body/MeasureRow.tsx | 35 +++++++++++++++-------- tests/FixedHeader.spec.jsx | 58 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/src/Body/MeasureCell.tsx b/src/Body/MeasureCell.tsx index 923c0dee6..be746e46f 100644 --- a/src/Body/MeasureCell.tsx +++ b/src/Body/MeasureCell.tsx @@ -1,19 +1,23 @@ import * as React from 'react'; +import ResizeObserver from '@rc-component/resize-observer'; export interface MeasureCellProps { + columnKey: React.Key; title?: React.ReactNode; } /** - * Measure cell only renders the td used for width measurement. - * Width reading is batched by MeasureRow (#1507). + * 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 { title } = props; + const { columnKey, title } = props; return ( - + + + ); }; diff --git a/src/Body/MeasureRow.tsx b/src/Body/MeasureRow.tsx index d8c9c86b8..707afbb6f 100644 --- a/src/Body/MeasureRow.tsx +++ b/src/Body/MeasureRow.tsx @@ -23,12 +23,13 @@ const MeasureRow: React.FC = ({ const { measureRowRender } = useContext(TableContext, ['measureRowRender']); - // Read every cell width in one synchronous pass. rc-table renders the - // measure row only with a fixed table layout (fixHeader, horizontal scroll - // or sticky), where a column width can only change when the row itself - // resizes or the column set changes, so one row-level observer plus a - // re-measure on column changes observes everything a per-cell - // ResizeObserver would, without one forced layout read per column (#1507). + // 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)) { @@ -43,7 +44,9 @@ const MeasureRow: React.FC = ({ }); }); - const columnsKeyStr = columnsKey.join('_'); + // 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(); @@ -52,18 +55,26 @@ const MeasureRow: React.FC = ({ }, [columnsKeyStr]); const measureRow = ( - - + + { + if (isVisible(ref.current)) { + infoList.forEach(({ data: columnKey, size }) => { + onColumnResize(columnKey, size.offsetWidth); + }); + } + }} + > {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 ; + return ; })} - - + + ); return typeof measureRowRender === 'function' ? measureRowRender(measureRow) : measureRow; diff --git a/tests/FixedHeader.spec.jsx b/tests/FixedHeader.spec.jsx index b6284a07f..6dae051fd 100644 --- a/tests/FixedHeader.spec.jsx +++ b/tests/FixedHeader.spec.jsx @@ -181,6 +181,64 @@ describe('Table.FixedHeader', () => { vi.useRealTimers(); }); + it('re-measures when new column keys collide in a joined identifier', async () => { + const { container, rerender } = render( +
-
{title || '\xa0'}
-
+
{title || '\xa0'}
+
, + ); + + 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 = [ {