Skip to content
108 changes: 108 additions & 0 deletions src/table/__tests__/columns-width.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,70 @@ test('prints a warning when resizable columns have non-numeric width', () => {
);
});

describe('measurement on re-render', () => {
const stableColumns: TableProps.ColumnDefinition<Item>[] = [
{ 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(
<Table
columnDefinitions={stableColumns}
items={defaultItems}
resizableColumns={true}
stickyColumns={{ first: 1 }}
/>
);

// 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(
<Table
columnDefinitions={stableColumns}
items={[{ id: 1, text: 'updated' }]}
resizableColumns={true}
stickyColumns={{ first: 1 }}
/>
);

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(
<Table
columnDefinitions={stableColumns}
items={defaultItems}
resizableColumns={true}
stickyColumns={{ first: 1 }}
/>
);

getBoundingClientRectSpy.mockClear();

// Re-render with an added column: widths must be recomputed, so measurement is expected.
rerender(
<Table
columnDefinitions={[...stableColumns, { id: 'extra', header: 'extra', cell: () => '-' }]}
items={defaultItems}
resizableColumns={true}
stickyColumns={{ first: 1 }}
/>
);

expect(getBoundingClientRectSpy).toHaveBeenCalled();

getBoundingClientRectSpy.mockRestore();
});
});

describe('with stickyHeader=true', () => {
const originalFn = window.CSS.supports;
beforeEach(() => {
Expand Down Expand Up @@ -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<Element> }> = [];
const OriginalResizeObserver = window.ResizeObserver;
window.ResizeObserver = class {
private entry: { callback: ResizeObserverCallback; elements: Set<Element> };
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<Item>[] = [{ id: 'id', header: 'id', cell: item => item.text }];
const { wrapper } = renderTable(<Table columnDefinitions={columns} items={defaultItems} stickyHeader={true} />);
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', () => {
Expand Down
36 changes: 19 additions & 17 deletions src/table/internal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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,
Expand Down
34 changes: 28 additions & 6 deletions src/table/use-column-widths.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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;
Expand Down
Loading