Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ffb5b06
fix(BaseTable): tree rerenders fix
chelentos Apr 24, 2026
07bda9f
docs: spec for making experimentalMemoization pay off in real consumers
Apr 27, 2026
82e6c7c
fix(BaseRow): wire MemoBaseCell into BaseRow under experimentalMemoiz…
Apr 27, 2026
1b618f7
fix(BaseDraggableRow): wire MemoBaseCell into draggable path under ex…
Apr 27, 2026
26fd3a4
feat(hooks): add dev-only useStableRefWarning hook
Apr 27, 2026
16e2965
feat(BaseTable): warn on unstable props under experimentalMemoization
Apr 27, 2026
0438d24
docs(storybook): add anti-pattern memoization demo story and migratio…
Apr 27, 2026
d5f8962
fix(storybook): split NameCell to avoid unconditional FanoutContext s…
Apr 27, 2026
167bf53
fix(BaseRow): remove aria-rowindex from memo comparator; fix anti-pat…
Apr 27, 2026
c592628
docs(spec): auto-row-state subscription design + failing regression test
Apr 27, 2026
844a44f
Revert "docs(spec): auto-row-state subscription design + failing regr…
Apr 27, 2026
8dc1e4a
docs(spec): smart TreeExpandableCell encapsulates useIsExpanded
Apr 27, 2026
40ad95f
docs(migration): treeExpandableCell is the recommended chevron path
Apr 27, 2026
c7cb31a
docs: spec and plans update
Apr 28, 2026
2cae068
docs(spec): remove RowStateContext — pure TanStack API for row state
Apr 29, 2026
cd1bb44
feat(BaseCell): add isExpanded/isSelected to MemoBaseCell comparator
Apr 29, 2026
5fa461c
refactor(BaseRow): remove RowStateContext; thread row state as props
Apr 29, 2026
81e97e5
docs(migration): row.getIsExpanded() works directly under memoization
Apr 29, 2026
bd67982
fix: stories fix
Apr 29, 2026
81e98ef
fix: stories fix
Apr 29, 2026
898879f
Merge branch 'fix/tree-rerenders' of https://github.com/gravity-ui/ta…
Apr 29, 2026
500ba96
docs(spec): design getRowVersion for experimentalMemoization
Apr 29, 2026
dd84a47
docs(plan): implementation plan for getRowVersion
Apr 29, 2026
7475eb4
feat(utils): add arraysShallowEqual helper for memo comparators
Apr 29, 2026
9d3985d
feat(BaseTable): add getRowVersion prop type
Apr 29, 2026
da9eeeb
refactor(memo): replace isSelected/isExpanded lift with getRowVersion…
Apr 29, 2026
5990aaf
test(BaseRow.memo): cover default + custom getRowVersion behavior
Apr 29, 2026
45e460c
docs(migration): rewrite for getRowVersion API
Apr 29, 2026
6866364
docs(README): add Memoization (experimental) section
Apr 29, 2026
75d477a
docs(README): add Memoization (experimental) section
Apr 29, 2026
93dba9c
Merge branch 'fix/tree-rerender-v2' of https://github.com/gravity-ui/…
Apr 29, 2026
fb5f8bc
Merge branch 'fix/tree-rerender-v2' of https://github.com/gravity-ui/…
Apr 29, 2026
6e1a6f7
Merge branch 'fix/tree-rerender-v2' of https://github.com/gravity-ui/…
Apr 29, 2026
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,36 @@ const TableSettingsDemo = () => {

Learn more about the table and the column resizing properties in the react-table [docs](https://tanstack.com/table/v8/docs/api/features/column-sizing)

## Memoization (experimental)

Pass `experimentalMemoization` on `Table` / `BaseTable` to enable
`React.memo` on rows and cells. This avoids re-rendering every row and cell
when one row's state changes. The flag is opt-in; without it the rendering
behavior is unchanged.

```tsx
<Table table={table} experimentalMemoization />
```

By default, the memo comparator tracks `row.getIsSelected()` and
`row.getIsExpanded()`. If your custom cells read other row state (or external
state keyed by row id), declare it via `getRowVersion`:

```tsx
const getRowVersion = (row: Row<MyData>) =>
[row.getIsSelected(), row.getIsExpanded(), row.getIsPinned()] as const;

<Table table={table} experimentalMemoization getRowVersion={getRowVersion} />;
```

`getRowVersion` is called once per row per parent render. Returned values are
compared element-wise with `Object.is` — any change invalidates the row's memo
and re-renders only that row's cells.

See [`docs/MIGRATION-experimentalMemoization.md`](docs/MIGRATION-experimentalMemoization.md)
for anti-patterns that defeat memoization, the verification recipe, and a
worked migration example.

## Known Issues and Compatibility

### React 19 + React Compiler Compatibility
Expand Down
21 changes: 21 additions & 0 deletions src/components/BaseCell/BaseCell.memo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';

import {arraysShallowEqual} from '../../utils';

import type {BaseCellProps} from './BaseCell';
import {BaseCell} from './BaseCell';

function areCellPropsEqual<TData>(prev: BaseCellProps<TData>, next: BaseCellProps<TData>): boolean {
return (
arraysShallowEqual(prev._rowVersion ?? [], next._rowVersion ?? []) &&
prev.cell === next.cell &&
prev.className === next.className &&
prev.attributes === next.attributes &&
prev.style === next.style &&
prev.children === next.children &&
prev.colSpan === next.colSpan &&
prev['aria-colindex'] === next['aria-colindex']
);
}

export const MemoBaseCell = React.memo(BaseCell, areCellPropsEqual) as typeof BaseCell;
2 changes: 2 additions & 0 deletions src/components/BaseCell/BaseCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface BaseCellProps<TData>
attributes?:
| React.TdHTMLAttributes<HTMLTableCellElement>
| ((cell?: Cell<TData, unknown>) => React.TdHTMLAttributes<HTMLTableCellElement>);
_rowVersion?: readonly unknown[];
}

export const BaseCell = <TData,>({
Expand All @@ -21,6 +22,7 @@ export const BaseCell = <TData,>({
className: classNameProp,
style,
attributes: attributesProp,
_rowVersion: _rowVersionDiscarded,
...restProps
}: BaseCellProps<TData>) => {
const attributes = typeof attributesProp === 'function' ? attributesProp(cell) : attributesProp;
Expand Down
1 change: 1 addition & 0 deletions src/components/BaseCell/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './BaseCell';
export * from './BaseCell.memo';
62 changes: 62 additions & 0 deletions src/components/BaseDraggableRow/BaseDraggableRow.memo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from 'react';

import {arraysShallowEqual} from '../../utils';
import {MemoBaseCell} from '../BaseCell/BaseCell.memo';

import {BaseDraggableRow} from './BaseDraggableRow';
import type {BaseDraggableRowProps} from './BaseDraggableRow';

export interface MemoBaseDraggableRowProps<
TData,
TScrollElement extends Element | Window = HTMLDivElement,
> extends BaseDraggableRowProps<TData, TScrollElement> {
_rowVersion: readonly unknown[];
}

// eslint-disable-next-line complexity
function areEqual<TData, TScrollElement extends Element | Window>(
prev: Readonly<BaseDraggableRowProps<TData, TScrollElement>>,
next: Readonly<BaseDraggableRowProps<TData, TScrollElement>>,
): boolean {
return (
prev.row === next.row &&
arraysShallowEqual(prev._rowVersion ?? [], next._rowVersion ?? []) &&
prev.table === next.table &&
prev.virtualItem?.start === next.virtualItem?.start &&
prev.virtualItem?.size === next.virtualItem?.size &&
prev.style === next.style &&
prev.cellClassName === next.cellClassName &&
prev.className === next.className &&
prev.onClick === next.onClick &&
prev.getIsCustomRow === next.getIsCustomRow &&
prev.getIsGroupHeaderRow === next.getIsGroupHeaderRow &&
prev.renderCustomRowContent === next.renderCustomRowContent &&
prev.renderGroupHeader === next.renderGroupHeader &&
prev.renderGroupHeaderRowContent === next.renderGroupHeaderRowContent &&
prev.getGroupTitle === next.getGroupTitle &&
prev.groupHeaderClassName === next.groupHeaderClassName &&
prev.attributes === next.attributes &&
prev.cellAttributes === next.cellAttributes &&
prev.rowVirtualizer === next.rowVirtualizer &&
prev['aria-rowindex'] === next['aria-rowindex'] &&
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In MemoBaseRow aria-rowindex was removed from the comparator, but here it's still compared. Should the logic be the same in both components?

prev['aria-selected'] === next['aria-selected']
);
}

const BaseDraggableRowWithMemoCell = React.forwardRef(function BaseDraggableRowWithMemoCellRender<
TData,
TScrollElement extends Element | Window,
>(props: BaseDraggableRowProps<TData, TScrollElement>, ref: React.Ref<HTMLTableRowElement>) {
return <BaseDraggableRow {...props} Cell={MemoBaseCell} ref={ref} />;
}) as <TData, TScrollElement extends Element | Window = HTMLDivElement>(
props: BaseDraggableRowProps<TData, TScrollElement> & {ref?: React.Ref<HTMLTableRowElement>},
) => React.ReactElement;

export const MemoBaseDraggableRow = React.memo(BaseDraggableRowWithMemoCell, areEqual) as <
TData,
TScrollElement extends Element | Window = HTMLDivElement,
>(
props: MemoBaseDraggableRowProps<TData, TScrollElement> & {
ref?: React.Ref<HTMLTableRowElement>;
},
) => React.ReactElement;
1 change: 1 addition & 0 deletions src/components/BaseDraggableRow/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './BaseDraggableRow';
export * from './BaseDraggableRow.memo';
6 changes: 4 additions & 2 deletions src/components/BaseGroupHeader/BaseGroupHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type * as React from 'react';
import * as React from 'react';

import type {Row} from '@tanstack/react-table';

Expand All @@ -17,11 +17,13 @@ export const BaseGroupHeader = <TData,>({
className,
getGroupTitle,
}: BaseGroupHeaderProps<TData>) => {
const isExpanded = row.getIsExpanded();

return (
<h2 className={b(null, className)}>
<button className={b('button')} onClick={row.getToggleExpandedHandler()}>
<svg
className={b('icon', {expanded: row.getIsExpanded()})}
className={b('icon', {expanded: isExpanded})}
viewBox="0 0 16 16"
width="16"
height="16"
Expand Down
57 changes: 57 additions & 0 deletions src/components/BaseRow/BaseRow.memo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as React from 'react';

import {arraysShallowEqual} from '../../utils';
import {MemoBaseCell} from '../BaseCell/BaseCell.memo';

import type {BaseRowProps} from './BaseRow';
import {BaseRow} from './BaseRow';

export interface MemoBaseRowProps<TData, TScrollElement extends Element | Window = HTMLDivElement>
extends BaseRowProps<TData, TScrollElement> {
_rowVersion: readonly unknown[];
}

// eslint-disable-next-line complexity
function areEqual<TData, TScrollElement extends Element | Window>(
prev: Readonly<BaseRowProps<TData, TScrollElement>>,
next: Readonly<BaseRowProps<TData, TScrollElement>>,
): boolean {
return (
prev.row === next.row &&
arraysShallowEqual(prev._rowVersion ?? [], next._rowVersion ?? []) &&
prev.table === next.table &&
prev.virtualItem?.start === next.virtualItem?.start &&
prev.virtualItem?.size === next.virtualItem?.size &&
prev.style === next.style &&
prev.cellClassName === next.cellClassName &&
prev.className === next.className &&
prev.onClick === next.onClick &&
prev.getIsCustomRow === next.getIsCustomRow &&
prev.getIsGroupHeaderRow === next.getIsGroupHeaderRow &&
prev.renderCustomRowContent === next.renderCustomRowContent &&
prev.renderGroupHeader === next.renderGroupHeader &&
prev.renderGroupHeaderRowContent === next.renderGroupHeaderRowContent &&
prev.getGroupTitle === next.getGroupTitle &&
prev.groupHeaderClassName === next.groupHeaderClassName &&
prev.attributes === next.attributes &&
prev.cellAttributes === next.cellAttributes &&
prev.rowVirtualizer === next.rowVirtualizer &&
prev['aria-selected'] === next['aria-selected']
);
}

const BaseRowWithMemoCell = React.forwardRef(function BaseRowWithMemoCellRender<
TData,
TScrollElement extends Element | Window,
>(props: BaseRowProps<TData, TScrollElement>, ref: React.Ref<HTMLTableRowElement>) {
return <BaseRow {...props} Cell={MemoBaseCell} ref={ref} />;
}) as <TData, TScrollElement extends Element | Window = HTMLDivElement>(
props: BaseRowProps<TData, TScrollElement> & {ref?: React.Ref<HTMLTableRowElement>},
) => React.ReactElement;

export const MemoBaseRow = React.memo(BaseRowWithMemoCell, areEqual) as <
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't the new memoized components have displayName?

TData,
TScrollElement extends Element | Window = HTMLDivElement,
>(
props: MemoBaseRowProps<TData, TScrollElement> & {ref?: React.Ref<HTMLTableRowElement>},
) => React.ReactElement;
16 changes: 12 additions & 4 deletions src/components/BaseRow/BaseRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface BaseRowProps<TData, TScrollElement extends Element | Window = H
| React.HTMLAttributes<HTMLTableRowElement>
| ((row: Row<TData>) => React.HTMLAttributes<HTMLTableRowElement>);
cellAttributes?: BaseCellProps<TData>['attributes'];
Cell?: React.FunctionComponent<BaseCellProps<TData>>;
_rowVersion?: readonly unknown[];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this prop is not @internal? Ideally _rowVersion should be excluded from the public interfaces entirely.

}

export const BaseRow = React.forwardRef(
Expand All @@ -61,13 +63,17 @@ export const BaseRow = React.forwardRef(
virtualItem,
attributes: attributesProp,
cellAttributes,
Cell = BaseCell,
table: _,
_rowVersion,
...restProps
}: BaseRowProps<TData, TScrollElement>,
ref: React.Ref<HTMLTableRowElement>,
) => {
const rowRef = useForkRef(rowVirtualizer?.measureElement, ref);

const isSelected = row.getIsSelected();

const attributes =
typeof attributesProp === 'function' ? attributesProp(row) : attributesProp;

Expand Down Expand Up @@ -96,11 +102,12 @@ export const BaseRow = React.forwardRef(
getGroupTitle,
})
) : (
<BaseCell
<Cell
className={cellClassName}
colSpan={row.getVisibleCells().length}
attributes={cellAttributes}
aria-colindex={1}
_rowVersion={_rowVersion}
>
{renderGroupHeader ? (
renderGroupHeader({
Expand All @@ -115,7 +122,7 @@ export const BaseRow = React.forwardRef(
getGroupTitle={getGroupTitle}
/>
)}
</BaseCell>
</Cell>
);
}

Expand All @@ -126,12 +133,13 @@ export const BaseRow = React.forwardRef(
return row
.getVisibleCells()
.map((cell) => (
<BaseCell
<Cell
key={cell.id}
cell={cell}
className={cellClassName}
attributes={cellAttributes}
aria-colindex={cell.column.getIndex() + 1}
_rowVersion={_rowVersion}
/>
));
};
Expand All @@ -142,7 +150,7 @@ export const BaseRow = React.forwardRef(
className={b(
'row',
{
selected: row.getIsSelected(),
selected: isSelected,
interactive: Boolean(onClick),
},
className,
Expand Down
Loading
Loading