Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions src/hooks/useColumnsAutoSize/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,17 @@ const table = useTable({

#### Options

| Name | Type | Default | Description |
| ----------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `minWidth` | `number` | `50` | Minimum width in pixels for any column |
| `maxWidth` | `number` | `500` | Maximum width in pixels for any column |
| `padding` | `number` | `16` | Padding in pixels to add to content width for cells |
| `headerPadding` | `number` | `24` | Padding in pixels to add to content width for headers |
| `sampleSize` | `number` | `100` | Number of rows to sample for width calculation (helps with performance) |
| `measureHeaderText` | `boolean` | `true` | Whether to include header text in width calculation |
| `respectExistingWidths` | `boolean` | `true` | Whether to preserve columns with predefined widths (size, width, or matching minSize/maxSize) |
| `respectResizedWidths` | `boolean` | `true` | Whether to preserve column widths that were resized by the user |
| `measureOnce` | `boolean` | `false` | If `true`, widths are calculated only once (on first data render) and are NOT recalculated when data changes (e.g. sorting/filtering). Prevents columns from jumping on sort |
| Name | Type | Default | Description |
| ----------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `minWidth` | `number` | `50` | Minimum width in pixels for any column |
| `maxWidth` | `number` | `500` | Maximum width in pixels for any column |
| `padding` | `number` | `16` | Padding in pixels to add to content width for cells |
| `headerPadding` | `number` | `24` | Padding in pixels to add to content width for headers |
| `sampleSize` | `number` | `100` | Number of rows to sample for width calculation (helps with performance) |
| `measureHeaderText` | `boolean` | `true` | Whether to include header text in width calculation |
| `respectExistingWidths` | `boolean` | `true` | Whether to preserve columns with predefined widths (size, width, or matching minSize/maxSize) |
| `respectResizedWidths` | `boolean` | `true` | Whether to preserve column widths that were resized by the user |
| `measureOnce` | `boolean` | `false` | If `true`, widths are not recalculated when data changes, columns are removed, or columns are reordered. Adding a previously unmeasured column triggers recalculation |

#### Returns

Expand Down Expand Up @@ -140,7 +140,7 @@ const {columnWidths, isMeasuring, columnsWithAutoSizes, setTableInstance} = useA
2. **Sampling**: To optimize performance, it only samples a subset of rows (configurable via `sampleSize`).
3. **Optimization**: Different measuring techniques are used for text vs React components.
4. **Respect for User Actions**: Preserves user-resized column widths and pre-defined column widths.
5. **Reactivity**: Automatically updates when table data or columns change (unless `measureOnce` is enabled).
5. **Reactivity**: Automatically updates when table data or columns change. With `measureOnce`, only adding a previously unmeasured column triggers recalculation.

## Troubleshooting

Expand Down Expand Up @@ -187,13 +187,13 @@ experimentalUseColumnsAutoSize({

### Columns jump / resize when sorting or changing data

By default the hook recalculates widths whenever the data changes (including sorting, since the sampled rows change). To measure widths only once and keep them stable afterwards, enable `measureOnce`:
By default the hook recalculates widths whenever the data changes (including sorting, since the sampled rows change). To keep widths stable unless a previously unmeasured column is added, enable `measureOnce`:

```tsx
experimentalUseColumnsAutoSize({
columns,
options: {
measureOnce: true, // calculate once, never recalc on data/sort changes
measureOnce: true, // recalculate only when a previously unmeasured column is added
},
});
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import * as React from 'react';

import {Button, Text} from '@gravity-ui/uikit';
import type {ColumnDef, SortingState} from '@tanstack/react-table';

import {SortIndicator, Table} from '../../../components';
import {useTable} from '../../useTable';
import {useColumnsAutoSize} from '../useColumnsAutoSize';

type Row = {
name: string;
age: number;
visits: number;
status: string;
progress: number;
score: number;
};

const data: Row[] = [
{
name: 'Alexander Smith-Johnson',
age: 42,
visits: 128,
status: 'In progress',
progress: 64,
score: 8754,
},
{
name: 'Olivia Garcia-Williams',
age: 29,
visits: 2048,
status: 'Completed',
progress: 100,
score: 12450,
},
{
name: 'Benjamin Rodriguez',
age: 35,
visits: 512,
status: 'Draft',
progress: 18,
score: 6230,
},
];

const baseColumns: ColumnDef<Row>[] = [
{accessorKey: 'name', header: 'Name'},
{accessorKey: 'age', header: 'Age'},
];

const additionalColumns: ColumnDef<Row>[] = [
{accessorKey: 'visits', header: 'Visits with a long header'},
{accessorKey: 'status', header: 'Current status'},
{accessorKey: 'progress', header: 'Profile progress'},
{accessorKey: 'score', header: 'Total score'},
];

export const MeasureOnceColumnsChangeTable = () => {
const [additionalColumnCount, setAdditionalColumnCount] = React.useState(0);
const [measurementCount, setMeasurementCount] = React.useState(0);
const [sorting, setSorting] = React.useState<SortingState>([]);
const measuredWidthsRef = React.useRef<Record<string, number>>({});
const columns = React.useMemo(
() => [...baseColumns, ...additionalColumns.slice(0, additionalColumnCount)],
[additionalColumnCount],
);
const {columnWidths, columnsWithAutoSizes, isMeasuring, setTableInstance} = useColumnsAutoSize({
columns,
options: {
measureOnce: true,
// Cell padding plus enough room for the sort indicator rendered next to the header.
headerPadding: 48,
},
});
const table = useTable({
data,
columns: columnsWithAutoSizes,
enableSorting: true,
onSortingChange: setSorting,
state: {sorting},
});

React.useEffect(() => {
setTableInstance(table);
}, [setTableInstance, table]);

React.useEffect(() => {
if (Object.keys(columnWidths).length > 0 && measuredWidthsRef.current !== columnWidths) {
measuredWidthsRef.current = columnWidths;
setMeasurementCount((count) => count + 1);
}
}, [columnWidths]);

return (
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
<div style={{display: 'flex', alignItems: 'center', gap: 12}}>
<Button
disabled={additionalColumnCount === additionalColumns.length}
onClick={() => setAdditionalColumnCount((count) => count + 1)}
>
Add column
</Button>
<Button
disabled={additionalColumnCount === 0}
onClick={() => setAdditionalColumnCount((count) => count - 1)}
>
Remove column
</Button>
<Text variant="body-1">Width recalculations: {measurementCount}</Text>
<Text variant="body-1">Columns: {columns.length}</Text>
</div>
{isMeasuring ? (
<div />
) : (
<Table
table={table}
headerCellAttributes={{style: {whiteSpace: 'nowrap'}}}
renderSortIndicator={(props) => <SortIndicator {...props} />}
/>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {Meta, StoryObj} from '@storybook/react';

import {AutoSizedTable} from './AutoSizedTable';
import {MeasureOnceColumnsChangeTable} from './MeasureOnceColumnsChangeTable';
import {TableWithDynamicData} from './TableWithDynamicData';
import {columns} from './constants/columns';
import {columnsWithComplexComponents} from './constants/columnsWithComplexComponents';
Expand Down Expand Up @@ -168,3 +169,14 @@ export const LongText: Story = {
},
},
};

export const MeasureOnceColumnsChange: Story = {
render: MeasureOnceColumnsChangeTable,
parameters: {
docs: {
description: {
story: 'With measureOnce enabled, adding a previously unmeasured column increments the recalculation counter, while removing it does not.',
},
},
},
};
18 changes: 14 additions & 4 deletions src/hooks/useColumnsAutoSize/useColumnsAutoSize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,18 @@ export function useColumnsAutoSize<TData extends unknown>({
typeof useTable<TData>
> | null>(null);

const hasMeasuredRef = React.useRef<boolean>(false);
const measuredColumnIdsRef = React.useRef<Set<string>>(new Set());

const rows = tableInstance?.getRowModel().rows ?? emptyRows;

const sampledRows = rows.slice(0, options?.sampleSize ?? 100);
const columnSizing = tableInstance?.getState().columnSizing ?? emptyColumnSizing;

const rowsDataKey = sampledRows.map((row) => row.id).join(',');
const columnIds = columns.map(
(column) => column.id || ('accessorKey' in column && String(column.accessorKey)) || '',
);
const columnsKey = JSON.stringify(columnIds);

const measureCellWidth = useMeasureCellWidth({
renderElementForMeasure,
Expand Down Expand Up @@ -137,7 +141,9 @@ export function useColumnsAutoSize<TData extends unknown>({

setColumnWidths(newWidths);
setIsMeasuring(false);
hasMeasuredRef.current = true;
Object.keys(newWidths).forEach((columnId) =>
measuredColumnIdsRef.current.add(columnId),
);
},
100,
),
Expand All @@ -149,11 +155,15 @@ export function useColumnsAutoSize<TData extends unknown>({
return;
}

if (options?.measureOnce && hasMeasuredRef.current) {
if (
options?.measureOnce &&
columnIds.every((columnId) => measuredColumnIdsRef.current.has(columnId))
) {
return;
}

calculateWidths.cancel();

calculateWidths({
columnSizing,
columns,
Expand All @@ -162,7 +172,7 @@ export function useColumnsAutoSize<TData extends unknown>({
tableInstance,
...options,
});
}, [columnSizing, rowsDataKey]);
}, [columnSizing, rowsDataKey, columnsKey]);

const columnsWithAutoSizes = React.useMemo(
() => setColumnAutoSizes(columns, columnWidths, columnSizing),
Expand Down
Loading