From df5a3942cb5db296f37daf77d70e0dd100739056 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Thu, 11 Jun 2026 09:11:34 -0400 Subject: [PATCH 1/2] 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, From 625d066b4547a6f7995dc2edb836ccbb7929d4a3 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Fri, 10 Jul 2026 22:51:42 -0400 Subject: [PATCH 2/2] fix: Re-sync sticky header copies when auto-layout columns resize Memoizing visibleColumns stopped the ColumnWidthsProvider effect from re-firing on data-only re-renders. For auto-layout columns, changing the data (pagination/filtering) changes the rendered column widths, so the sticky header copies were left with stale widths. Observe the primary header cells with a ResizeObserver and re-sync the sticky copies when their widths actually change. This restores correctness while keeping the optimization: fixed-width and resizable columns don't resize on data changes, so the observer stays idle. --- src/table/__tests__/columns-width.test.tsx | 44 ++++++++++++++++++++++ src/table/use-column-widths.tsx | 34 ++++++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index d5ffe86bc9..9e4d5882cf 100644 --- a/src/table/__tests__/columns-width.test.tsx +++ b/src/table/__tests__/columns-width.test.tsx @@ -397,6 +397,50 @@ describe('with stickyHeader=true', () => { { minWidth: '', width: '', maxWidth: '' }, ]); }); + + test('re-syncs the sticky header copy when a primary cell changes width without a column change', () => { + // Auto-layout columns (no explicit width) resize with their content. When that happens + // without the column definitions changing (e.g. paginating), the sticky header copies + // must be re-synced from the primary cells via a ResizeObserver. + const observers: Array<{ callback: ResizeObserverCallback; elements: Set }> = []; + const OriginalResizeObserver = window.ResizeObserver; + window.ResizeObserver = class { + private entry: { callback: ResizeObserverCallback; elements: Set }; + constructor(callback: ResizeObserverCallback) { + this.entry = { callback, elements: new Set() }; + observers.push(this.entry); + } + observe(element: Element) { + this.entry.elements.add(element); + } + unobserve(element: Element) { + this.entry.elements.delete(element); + } + disconnect() { + this.entry.elements.clear(); + } + } as unknown as typeof ResizeObserver; + + try { + const columns: TableProps.ColumnDefinition[] = [{ id: 'id', header: 'id', cell: item => item.text }]; + const { wrapper } = renderTable(
); + const [fakeHeader, realHeader] = wrapper.findAll('thead'); + const realCell = realHeader.findAll('tr > *')[0].getElement(); + const fakeCell = fakeHeader.findAll('tr > *')[0].getElement(); + + // Simulate the primary cell growing (as auto-layout would when the data changes). + realCell.getBoundingClientRect = () => ({ width: 250 }) as DOMRect; + + // The provider must observe the primary header cell, so a content-driven resize re-syncs the copy. + const observersForCell = observers.filter(observer => observer.elements.has(realCell)); + expect(observersForCell.length).toBeGreaterThan(0); + observersForCell.forEach(observer => observer.callback([], observer as unknown as ResizeObserver)); + + expect(fakeCell.style.width).toBe('250px'); + } finally { + window.ResizeObserver = OriginalResizeObserver; + } + }); }); describe('with grouped columns', () => { diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index fbc232fd1d..29fa001036 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -154,6 +154,16 @@ export function ColumnWidthsProvider({ }; }; + // Mirrors the current primary cell widths onto the sticky header copies. + const syncStickyColumnWidths = useStableCallback(() => { + for (const { id } of visibleColumns) { + const element = stickyCellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(true, id)); + } + } + }); + // Imperatively sets width style for a cell avoiding React state. // This allows setting the style as soon container's size change is observed. const updateColumnWidths = useStableCallback(() => { @@ -180,14 +190,26 @@ export function ColumnWidthsProvider({ } // Sticky column widths must always be synchronized regardless of columnWidths state. - for (const { id } of visibleColumns) { - const element = stickyCellsRef.current.get(id); - if (element) { - setElementWidths(element, getColumnStyles(true, id)); - } - } + syncStickyColumnWidths(); }); + // Re-syncs the sticky header copies when the primary cells change width without a + // corresponding change to the column definitions. This happens with auto-layout + // columns whose widths depend on the rendered content (e.g. paginating or filtering). + // Fixed-width and resizable columns don't resize on data changes, so this stays idle. + // The effect re-observes when the set of visible columns changes, since that swaps + // the underlying cell elements. + useEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver(() => syncStickyColumnWidths()); + for (const element of cellsRef.current.values()) { + observer.observe(element); + } + return () => observer.disconnect(); + }, [syncStickyColumnWidths, visibleColumns]); + // Observes container size and requests an update to the last cell width as it depends on the container's width. useResizeObserver(containerRef, ({ contentBoxWidth: containerWidth }) => { containerWidthRef.current = containerWidth;