-
Notifications
You must be signed in to change notification settings - Fork 9
fix(tablev2): keep RenderRow stable #1120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development/1.0
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DATA_ROW>(); | ||
|
|
||
| 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<Row<DATA_ROW>[]>) => { | ||
| const rows = data; | ||
| const row = data[index]; | ||
| prepareRowRef.current(row); | ||
| const onSingleRowSelected = onSingleRowSelectedRef.current; | ||
|
|
||
| return ( | ||
| <TableRowMultiSelectable | ||
| {...rowProps} | ||
| isSelected={row.isSelected || activeRowId === row.id} | ||
| separationLineVariant={separationLineVariant} | ||
| className="tr" | ||
| > | ||
| {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 ( | ||
| <div | ||
| {...cellProps} | ||
| onClick={ | ||
| onSingleRowSelected | ||
| ? (event) => { | ||
| event.stopPropagation(); | ||
| handleMultipleSelectedRows( | ||
| selectedRowIds, | ||
| rows, | ||
| row, | ||
| index, | ||
| ); | ||
| } | ||
| : undefined | ||
| } | ||
| > | ||
| {cell.render('Cell')} | ||
| </div> | ||
| ); | ||
| } | ||
| return ( | ||
| <TableRowMultiSelectable | ||
| {...rowProps} | ||
| isSelected={row.isSelected || activeRowId === row.id} | ||
| separationLineVariant={separationLineVariant} | ||
| className="tr" | ||
| > | ||
| {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 ( | ||
| <div | ||
| {...cellProps} | ||
| onClick={ | ||
| onSingleRowSelected | ||
| ? (event) => { | ||
| event.stopPropagation(); | ||
| handleMultipleSelectedRowsRef.current( | ||
| selectedRowIdsRef.current, | ||
| rows, | ||
| row, | ||
| index, | ||
| ); | ||
| } | ||
| : undefined | ||
| } | ||
| > | ||
| {cell.render('Cell')} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div {...cellProps} className="td"> | ||
| {cell.render('Cell')} | ||
| </div> | ||
| ); | ||
| })} | ||
| </TableRowMultiSelectable> | ||
| ); | ||
| }, areEqual); | ||
| return ( | ||
| <div {...cellProps} className="td"> | ||
| {cell.render('Cell')} | ||
| </div> | ||
| ); | ||
| })} | ||
| </TableRowMultiSelectable> | ||
| ); | ||
| }, areEqual), | ||
| [activeRowId, separationLineVariant], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
To keep the component stable across selection changes, move const activeRowIdRef = useRef<string | null>(null);
activeRowIdRef.current = activeRowId;
// in useMemo deps: remove activeRowId, keep only [separationLineVariant]
// in the row component: read activeRowIdRef.current instead of activeRowId
// pass activeRowId through itemData so areEqual detects the change:
// itemData={useMemo(() => ({ rows, activeRowId }), [rows, activeRowId])}This changes the |
||
| ); | ||
|
|
||
| return ( | ||
| <> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 ( | ||||||||||||||||||||||||
| <TableRow | ||||||||||||||||||||||||
| {...rowProps} | ||||||||||||||||||||||||
| isSelected={selectedId === row.id} | ||||||||||||||||||||||||
| aria-selected={selectedId === row.id ? 'true' : 'false'} | ||||||||||||||||||||||||
| separationLineVariant={separationLineVariant} | ||||||||||||||||||||||||
| selectedId={selectedId} | ||||||||||||||||||||||||
| className="tr" | ||||||||||||||||||||||||
| > | ||||||||||||||||||||||||
| {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<Row<DATA_ROW>[]>) => { | ||||||||||||||||||||||||
| const row = data[index]; | ||||||||||||||||||||||||
| prepareRowRef.current(row); | ||||||||||||||||||||||||
| const onRowSelected = onRowSelectedRef.current; | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same issue as in
Suggested change
|
||||||||||||||||||||||||
| 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 ( | ||||||||||||||||||||||||
| <div {...cellProps} className="td"> | ||||||||||||||||||||||||
| {cell.render('Cell')} | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| })} | ||||||||||||||||||||||||
| </TableRow> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| }, areEqual); | ||||||||||||||||||||||||
| tabIndex: onRowSelected ? 0 : undefined, | ||||||||||||||||||||||||
| onKeyDown: (event) => { | ||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||
| onRowSelected && | ||||||||||||||||||||||||
| (event.key === ' ' || | ||||||||||||||||||||||||
| event.key === 'Enter' || | ||||||||||||||||||||||||
| event.key === 'Spacebar') | ||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||
| event.preventDefault(); | ||||||||||||||||||||||||
| onRowSelected(row); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||
| <TableRow | ||||||||||||||||||||||||
| {...rowProps} | ||||||||||||||||||||||||
| isSelected={selectedId === row.id} | ||||||||||||||||||||||||
| aria-selected={selectedId === row.id ? 'true' : 'false'} | ||||||||||||||||||||||||
| separationLineVariant={separationLineVariant} | ||||||||||||||||||||||||
| selectedId={selectedId} | ||||||||||||||||||||||||
| className="tr" | ||||||||||||||||||||||||
| > | ||||||||||||||||||||||||
| {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 ( | ||||||||||||||||||||||||
| <div {...cellProps} className="td"> | ||||||||||||||||||||||||
| {cell.render('Cell')} | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| })} | ||||||||||||||||||||||||
| </TableRow> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| }, areEqual), | ||||||||||||||||||||||||
| [selectedId, separationLineVariant], | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const { hasScrollbar, scrollBarWidth, handleScrollbarWidth } = | ||||||||||||||||||||||||
| useTableScrollbar(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ListChildComponentProps<Row<DATA_ROW>[]>>; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| listRef?: Ref<FixedSizeList<Row<DATA_ROW>[]>>; | ||
| }; | ||
| export function TableRows< | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onSingleRowSelectedis captured from the ref at render time into a local variable, then used in the click handler ternary on line 169. The other refs (handleMultipleSelectedRowsRef,toggleAllRowsSelectedRef,selectedRowIdsRef) are correctly read from.currentat click time — but this one isn't, so if the prop changes after the row renders, the click handler uses a stale reference and may pick the wrong branch (single-select vs multi-select).Read the ref inside the click handler instead:
The same change applies to the checkbox
onClickon the selection column (line 207) — readonSingleRowSelectedRef.currentthere too.