diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 9ec3a24158..9e4d5882cf 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(() => { @@ -333,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/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, 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;