diff --git a/src/hooks/useColumnsAutoSize/README.md b/src/hooks/useColumnsAutoSize/README.md index 0bdd63a..fbb0a89 100644 --- a/src/hooks/useColumnsAutoSize/README.md +++ b/src/hooks/useColumnsAutoSize/README.md @@ -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 @@ -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 @@ -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 }, }); ``` diff --git a/src/hooks/useColumnsAutoSize/__stories__/MeasureOnceColumnsChangeTable.tsx b/src/hooks/useColumnsAutoSize/__stories__/MeasureOnceColumnsChangeTable.tsx new file mode 100644 index 0000000..39a9dbe --- /dev/null +++ b/src/hooks/useColumnsAutoSize/__stories__/MeasureOnceColumnsChangeTable.tsx @@ -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[] = [ + {accessorKey: 'name', header: 'Name'}, + {accessorKey: 'age', header: 'Age'}, +]; + +const additionalColumns: ColumnDef[] = [ + {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([]); + const measuredWidthsRef = React.useRef>({}); + 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 ( +
+
+ + + Width recalculations: {measurementCount} + Columns: {columns.length} +
+ {isMeasuring ? ( +
+ ) : ( + } + /> + )} + + ); +}; diff --git a/src/hooks/useColumnsAutoSize/__stories__/useColumnsAutoSize.stories.tsx b/src/hooks/useColumnsAutoSize/__stories__/useColumnsAutoSize.stories.tsx index b35d68a..50dacdf 100644 --- a/src/hooks/useColumnsAutoSize/__stories__/useColumnsAutoSize.stories.tsx +++ b/src/hooks/useColumnsAutoSize/__stories__/useColumnsAutoSize.stories.tsx @@ -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'; @@ -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.', + }, + }, + }, +}; diff --git a/src/hooks/useColumnsAutoSize/useColumnsAutoSize.tsx b/src/hooks/useColumnsAutoSize/useColumnsAutoSize.tsx index 96af37d..4305989 100644 --- a/src/hooks/useColumnsAutoSize/useColumnsAutoSize.tsx +++ b/src/hooks/useColumnsAutoSize/useColumnsAutoSize.tsx @@ -31,7 +31,7 @@ export function useColumnsAutoSize({ typeof useTable > | null>(null); - const hasMeasuredRef = React.useRef(false); + const measuredColumnIdsRef = React.useRef>(new Set()); const rows = tableInstance?.getRowModel().rows ?? emptyRows; @@ -39,6 +39,10 @@ export function useColumnsAutoSize({ 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, @@ -137,7 +141,9 @@ export function useColumnsAutoSize({ setColumnWidths(newWidths); setIsMeasuring(false); - hasMeasuredRef.current = true; + Object.keys(newWidths).forEach((columnId) => + measuredColumnIdsRef.current.add(columnId), + ); }, 100, ), @@ -149,11 +155,15 @@ export function useColumnsAutoSize({ return; } - if (options?.measureOnce && hasMeasuredRef.current) { + if ( + options?.measureOnce && + columnIds.every((columnId) => measuredColumnIdsRef.current.has(columnId)) + ) { return; } calculateWidths.cancel(); + calculateWidths({ columnSizing, columns, @@ -162,7 +172,7 @@ export function useColumnsAutoSize({ tableInstance, ...options, }); - }, [columnSizing, rowsDataKey]); + }, [columnSizing, rowsDataKey, columnsKey]); const columnsWithAutoSizes = React.useMemo( () => setColumnAutoSizes(columns, columnWidths, columnSizing),