From 8fb49f098a0e01eee2b8c559c3ffa0e34c2262ce Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Fri, 29 May 2026 15:14:06 +0200 Subject: [PATCH 1/2] fix(tablev2): keep RenderRow stable so rows aren't remounted on re-render RenderRow was redefined inline on every render, so react-window saw a new component type and remounted every row (and cell) whenever the table re-rendered, making async cell content reload and flash. Read the row from react-window's itemData and volatile callbacks from refs so RenderRow keeps a stable identity. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tablev2/MultiSelectableContent.tsx | 181 +++++++++++------- .../tablev2/SingleSelectableContent.tsx | 153 ++++++++------- src/lib/components/tablev2/TableCommon.tsx | 4 +- 3 files changed, 198 insertions(+), 140 deletions(-) diff --git a/src/lib/components/tablev2/MultiSelectableContent.tsx b/src/lib/components/tablev2/MultiSelectableContent.tsx index fa817d3d40..3ddea678bf 100644 --- a/src/lib/components/tablev2/MultiSelectableContent.tsx +++ b/src/lib/components/tablev2/MultiSelectableContent.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState, memo, CSSProperties } from 'react'; +import { useEffect, useState, memo, useMemo, useRef } from 'react'; import { Row } from 'react-table'; -import { areEqual } from 'react-window'; +import { areEqual, ListChildComponentProps } from 'react-window'; import { useTableContext } from './Tablev2.component'; import { HeadRow, @@ -14,7 +14,7 @@ import { TableLocalType, TableVariantType, } from './TableUtils'; -import { RenderRowType, TableRows, useTableScrollbar } from './TableCommon'; +import { TableRows, useTableScrollbar } from './TableCommon'; import useSyncedScroll from './useSyncedScroll'; import { Box } from '../box/Box'; import { Loader } from '../loader/Loader.component'; @@ -124,79 +124,116 @@ export const MultiSelectableContent = < const { headerRef } = useSyncedScroll(); - const RenderRow = memo(({ index, style }: RenderRowType) => { - const row = rows[index]; - prepareRow(row); + /** + * These values change identity on (almost) every render. We read them through refs so the row + * renderer below can keep a stable identity (see RenderRow). + */ + const prepareRowRef = useRef(prepareRow); + prepareRowRef.current = prepareRow; + const selectedRowIdsRef = useRef(selectedRowIds); + selectedRowIdsRef.current = selectedRowIds; + const onSingleRowSelectedRef = useRef(onSingleRowSelected); + onSingleRowSelectedRef.current = onSingleRowSelected; + const toggleAllRowsSelectedRef = useRef(toggleAllRowsSelected); + toggleAllRowsSelectedRef.current = toggleAllRowsSelected; + const handleMultipleSelectedRowsRef = useRef(handleMultipleSelectedRows); + handleMultipleSelectedRowsRef.current = handleMultipleSelectedRows; - const rowProps = { - ...row.getRowProps({ - /** - * Note:We need to pass the style property to the row component. - * Otherwise when we scroll down, the next rows are flashing - * because they are re-rendered in loop. - */ - style: { ...style }, - }), - onClick: onSingleRowSelected - ? () => { - onSingleRowSelected(row); - toggleAllRowsSelected(false); - setActiveRowId(row.id); - } - : () => handleMultipleSelectedRows(selectedRowIds, rows, row, index), - }; + /** + * RenderRow MUST keep a stable identity across re-renders. It used to be redefined inline on + * every render, so react-window saw a new component type each time and remounted (not just + * re-rendered) every row — and therefore every cell — whenever the table re-rendered for any + * reason. That made async cell content reload and flash. We now read the row from react-window's + * `data` (itemData) prop and the volatile callbacks/state from refs, so the component is only + * recreated when something that affects the rendered output (activeRowId / separationLineVariant) + * actually changes. Checkbox selection still updates because react-table rebuilds `rows` (and + * therefore `data`) when `selectedRowIds` changes. + */ + const RenderRow = useMemo( + () => + memo(({ index, style, data }: ListChildComponentProps[]>) => { + const rows = data; + const row = data[index]; + prepareRowRef.current(row); + const onSingleRowSelected = onSingleRowSelectedRef.current; - return ( - - {row.cells.map((cell) => { - const cellProps = cell.getCellProps({ - style: { - ...cell.column.cellStyle, - // Vertically center the text in cells. - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - }, - role: 'gridcell', - }); + const rowProps = { + ...row.getRowProps({ + /** + * Note:We need to pass the style property to the row component. + * Otherwise when we scroll down, the next rows are flashing + * because they are re-rendered in loop. + */ + style: { ...style }, + }), + onClick: onSingleRowSelected + ? () => { + onSingleRowSelected(row); + toggleAllRowsSelectedRef.current(false); + setActiveRowId(row.id); + } + : () => + handleMultipleSelectedRowsRef.current( + selectedRowIdsRef.current, + rows, + row, + index, + ), + }; - if (cell.column.id === 'selection') { - return ( -
{ - event.stopPropagation(); - handleMultipleSelectedRows( - selectedRowIds, - rows, - row, - index, - ); - } - : undefined - } - > - {cell.render('Cell')} -
- ); - } + return ( + + {row.cells.map((cell) => { + const cellProps = cell.getCellProps({ + style: { + ...cell.column.cellStyle, + // Vertically center the text in cells. + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }, + role: 'gridcell', + }); + + if (cell.column.id === 'selection') { + return ( +
{ + event.stopPropagation(); + handleMultipleSelectedRowsRef.current( + selectedRowIdsRef.current, + rows, + row, + index, + ); + } + : undefined + } + > + {cell.render('Cell')} +
+ ); + } - return ( -
- {cell.render('Cell')} -
- ); - })} -
- ); - }, areEqual); + return ( +
+ {cell.render('Cell')} +
+ ); + })} +
+ ); + }, areEqual), + [activeRowId, separationLineVariant], + ); return ( <> diff --git a/src/lib/components/tablev2/SingleSelectableContent.tsx b/src/lib/components/tablev2/SingleSelectableContent.tsx index 43784e1212..4c50debbf5 100644 --- a/src/lib/components/tablev2/SingleSelectableContent.tsx +++ b/src/lib/components/tablev2/SingleSelectableContent.tsx @@ -1,5 +1,5 @@ -import { memo, useEffect, useRef } from 'react'; -import { areEqual, FixedSizeList } from 'react-window'; +import { memo, useEffect, useMemo, useRef } from 'react'; +import { areEqual, FixedSizeList, ListChildComponentProps } from 'react-window'; import { Row } from 'react-table'; import { useTableContext } from './Tablev2.component'; import { @@ -14,7 +14,7 @@ import { TableLocalType, TableVariantType, } from './TableUtils'; -import { RenderRowType, TableRows, useTableScrollbar } from './TableCommon'; +import { TableRows, useTableScrollbar } from './TableCommon'; import useSyncedScroll from './useSyncedScroll'; import { Loader } from '../loader/Loader.component'; import { Box } from '../box/Box'; @@ -77,69 +77,92 @@ export function SingleSelectableContent< return () => clearTimeout(timer); }, [autoScrollToSelected, selectedId, rows]); - const RenderRow = memo(({ index, style }: RenderRowType) => { - const row = rows[index]; - prepareRow(row); - let rowProps = row.getRowProps({ - /** - * Note: We need to pass the style property to the row component. - * Otherwise when we scroll down, the next rows are flashing - * because they are re-rendered in loop. - */ - style: { ...style }, - }); - - rowProps = { - ...rowProps, - ...{ - onClick: () => { - if (onRowSelected) return onRowSelected(row); - }, - tabIndex: onRowSelected ? 0 : undefined, - onKeyDown: (event) => { - if ( - onRowSelected && - (event.key === ' ' || - event.key === 'Enter' || - event.key === 'Spacebar') - ) { - event.preventDefault(); - onRowSelected(row); - } - }, - }, - }; - - return ( - - {row.cells.map((cell) => { - let cellProps = cell.getCellProps({ - style: { - ...cell.column.cellStyle, - // Vertically center the text in cells. - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', + /** + * `prepareRow` and `onRowSelected` change identity on every render. We read them through refs + * so the row renderer below can keep a stable identity (see RenderRow). + */ + const prepareRowRef = useRef(prepareRow); + prepareRowRef.current = prepareRow; + const onRowSelectedRef = useRef(onRowSelected); + onRowSelectedRef.current = onRowSelected; + + /** + * RenderRow MUST keep a stable identity across re-renders. It used to be redefined inline on + * every render, so react-window saw a new component type each time and remounted (not just + * re-rendered) every row — and therefore every cell — whenever the table re-rendered for any + * reason. That made async cell content reload and flash. We now read the row from react-window's + * `data` (itemData) prop and the volatile callbacks from refs, so the component only needs to be + * recreated when something that affects the rendered output (selectedId / separationLineVariant) + * actually changes. + */ + const RenderRow = useMemo( + () => + memo(({ index, style, data }: ListChildComponentProps[]>) => { + const row = data[index]; + prepareRowRef.current(row); + const onRowSelected = onRowSelectedRef.current; + let rowProps = row.getRowProps({ + /** + * Note: We need to pass the style property to the row component. + * Otherwise when we scroll down, the next rows are flashing + * because they are re-rendered in loop. + */ + style: { ...style }, + }); + + rowProps = { + ...rowProps, + ...{ + onClick: () => { + if (onRowSelected) return onRowSelected(row); }, - role: 'gridcell', - }); - - return ( -
- {cell.render('Cell')} -
- ); - })} -
- ); - }, areEqual); + tabIndex: onRowSelected ? 0 : undefined, + onKeyDown: (event) => { + if ( + onRowSelected && + (event.key === ' ' || + event.key === 'Enter' || + event.key === 'Spacebar') + ) { + event.preventDefault(); + onRowSelected(row); + } + }, + }, + }; + + return ( + + {row.cells.map((cell) => { + let cellProps = cell.getCellProps({ + style: { + ...cell.column.cellStyle, + // Vertically center the text in cells. + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }, + role: 'gridcell', + }); + + return ( +
+ {cell.render('Cell')} +
+ ); + })} +
+ ); + }, areEqual), + [selectedId, separationLineVariant], + ); const { hasScrollbar, scrollBarWidth, handleScrollbarWidth } = useTableScrollbar(); diff --git a/src/lib/components/tablev2/TableCommon.tsx b/src/lib/components/tablev2/TableCommon.tsx index bb3543ed6a..cbe27d156b 100644 --- a/src/lib/components/tablev2/TableCommon.tsx +++ b/src/lib/components/tablev2/TableCommon.tsx @@ -128,9 +128,7 @@ type TableRowsProps< locale?: TableLocalType; children?: (children: JSX.Element) => JSX.Element; customItemKey?: (index: number, data: DATA_ROW) => string; - RenderRow: React.MemoExoticComponent< - ({ index, style }: RenderRowType) => JSX.Element - >; + RenderRow: ComponentType[]>>; listRef?: Ref[]>>; }; export function TableRows< From f463fc47d10e4fd21fdcaa9c76b296d9d5b18750 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Mon, 1 Jun 2026 12:16:25 +0200 Subject: [PATCH 2/2] docs(tablev2): document memoizing columns to avoid cell remounts Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/tablev2/Tablev2.component.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/components/tablev2/Tablev2.component.tsx b/src/lib/components/tablev2/Tablev2.component.tsx index eb59465883..b9b879b107 100644 --- a/src/lib/components/tablev2/Tablev2.component.tsx +++ b/src/lib/components/tablev2/Tablev2.component.tsx @@ -53,6 +53,18 @@ export type CellProps< export type TableProps< DATA_ROW extends Record = Record, > = { + /** + * Column definitions for the table. + * + * IMPORTANT: memoize this (e.g. `useMemo`) and keep each column's `Cell` + * renderer stable across renders. react-table renders cells as + * ``, so a new `Cell` function identity on every parent + * render is a new component type — React unmounts and remounts the whole + * cell subtree each render. For cells with async content or icons that + * causes flicker/refetch. Define columns outside the render or wrap them in + * `useMemo`, and hoist inline `Cell` components rather than redefining them + * inline. The same applies to `data`. + */ columns: Array>; defaultSortingKey?: string; // We don't display the default sort key in the URL, so we need to specify here