From df5a3942cb5db296f37daf77d70e0dd100739056 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Thu, 11 Jun 2026 09:11:34 -0400 Subject: [PATCH] fix: Avoid redundant table column measurements on re-render Memoize the visible column definitions and the width/id arrays derived from them in InternalTable. These were rebuilt as fresh arrays on every render, so the reference passed to ColumnWidthsProvider always changed. That re-triggered the provider's width-sync effect, which calls getBoundingClientRect() on every render even when columns are unchanged (e.g. typing in the filter box), contributing to interaction lag. With the columns memoized, the effect only re-runs when the columns actually change. Adds tests asserting that re-rendering with unchanged columns performs no DOM measurements, while changing columns still does. --- src/table/__tests__/columns-width.test.tsx | 64 ++++++++++++++++++++++ src/table/internal.tsx | 36 ++++++------ 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 9ec3a24158..d5ffe86bc9 100644 --- a/src/table/__tests__/columns-width.test.tsx +++ b/src/table/__tests__/columns-width.test.tsx @@ -296,6 +296,70 @@ test('prints a warning when resizable columns have non-numeric width', () => { ); }); +describe('measurement on re-render', () => { + const stableColumns: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'id', cell: item => item.id, width: 150 }, + { id: 'text', header: 'text', cell: item => item.text, width: 200 }, + ]; + + test('does not measure column widths when re-rendering with unchanged columns', () => { + const getBoundingClientRectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); + const { rerender } = renderTable( + + ); + + // Ignore measurements from the initial render; only re-renders should be measurement-free. + getBoundingClientRectSpy.mockClear(); + + // Re-render with new items but the same column definitions (e.g. typing in the filter box). + rerender( +
+ ); + + expect(getBoundingClientRectSpy).not.toHaveBeenCalled(); + + getBoundingClientRectSpy.mockRestore(); + }); + + test('does measure column widths when the columns change', () => { + const getBoundingClientRectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); + const { rerender } = renderTable( +
+ ); + + getBoundingClientRectSpy.mockClear(); + + // Re-render with an added column: widths must be recomputed, so measurement is expected. + rerender( +
'-' }]} + items={defaultItems} + resizableColumns={true} + stickyColumns={{ first: 1 }} + /> + ); + + expect(getBoundingClientRectSpy).toHaveBeenCalled(); + + getBoundingClientRectSpy.mockRestore(); + }); +}); + describe('with stickyHeader=true', () => { const originalFn = window.CSS.supports; beforeEach(() => { diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 800214b57e..d58a59f1f9 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useCallback, useImperativeHandle, useRef } from 'react'; +import React, { useCallback, useImperativeHandle, useMemo, useRef } from 'react'; import clsx from 'clsx'; import { useContainerQuery } from '@cloudscape-design/component-toolkit'; @@ -299,11 +299,10 @@ const InternalTable = React.forwardRef( const { moveFocusDown, moveFocusUp, moveFocus } = useSelectionFocusMove(selectionType, allItems.length); const { onRowClickHandler, onRowContextMenuHandler } = useRowEvents({ onRowClick, onRowContextMenu }); - const visibleColumnDefinitions = getVisibleColumnDefinitions({ - columnDefinitions, - columnDisplay, - visibleColumns, - }); + const visibleColumnDefinitions = useMemo( + () => getVisibleColumnDefinitions({ columnDefinitions, columnDisplay, visibleColumns }), + [columnDefinitions, columnDisplay, visibleColumns] + ); const visibleColumnIds = visibleColumnDefinitions.map((col, idx) => getColumnKey(col, idx).toString()); @@ -374,17 +373,20 @@ const InternalTable = React.forwardRef( headerIdRef.current = id; }, []); - const visibleColumnWidthsWithSelection: ColumnWidthDefinition[] = []; - const visibleColumnIdsWithSelection: PropertyKey[] = []; - if (hasSelection) { - visibleColumnWidthsWithSelection.push({ id: selectionColumnId, width: SELECTION_COLUMN_WIDTH }); - visibleColumnIdsWithSelection.push(selectionColumnId); - } - for (let columnIndex = 0; columnIndex < visibleColumnDefinitions.length; columnIndex++) { - const columnId = getColumnKey(visibleColumnDefinitions[columnIndex], columnIndex); - visibleColumnWidthsWithSelection.push({ ...visibleColumnDefinitions[columnIndex], id: columnId }); - visibleColumnIdsWithSelection.push(columnId); - } + const { visibleColumnWidthsWithSelection, visibleColumnIdsWithSelection } = useMemo(() => { + const widths: ColumnWidthDefinition[] = []; + const ids: PropertyKey[] = []; + if (hasSelection) { + widths.push({ id: selectionColumnId, width: SELECTION_COLUMN_WIDTH }); + ids.push(selectionColumnId); + } + for (let columnIndex = 0; columnIndex < visibleColumnDefinitions.length; columnIndex++) { + const columnId = getColumnKey(visibleColumnDefinitions[columnIndex], columnIndex); + widths.push({ ...visibleColumnDefinitions[columnIndex], id: columnId }); + ids.push(columnId); + } + return { visibleColumnWidthsWithSelection: widths, visibleColumnIdsWithSelection: ids }; + }, [hasSelection, visibleColumnDefinitions]); const stickyState = useStickyColumns({ visibleColumns: visibleColumnIdsWithSelection,