From 0a9c605e9901b8eb4004a03853ef9ea26c93fa05 Mon Sep 17 00:00:00 2001 From: Pete Steyert-Woods Date: Tue, 20 Oct 2026 16:18:40 +0100 Subject: [PATCH] fix: avoid re-rendering unchanged Table rows and cells Row and Cell read TableStateContext, whose value changes whenever the collection, selection or expanded keys change, so every row and cell re-ran its hooks on any table update, even when the rendered elements were cached. Row and Cell now render their existing bodies inside a memoised component that only re-renders when a render key, computed from the parts of the table state the row or cell displays, changes. Each render passes its own table state to the bodies (useLatestTableState). Once that render commits, a layout effect points it at the latest committed state, so event handlers in rows that skipped later renders still act on the latest collection, selection and callbacks. Refs are only written in layout effects, never during render. Table-wide values that affect every row are compared through a key that changes only when one of them does. Adds a test that runs the same interactions against Table and against a copy with memoisation disabled and checks the DOM always matches, and a "Tree Table render counts" story that flashes rows and cells as they render. Co-Authored-By: Claude Opus 5.5 --- packages/react-aria-components/src/Table.tsx | 191 ++++++++- .../stories/Table.stories.tsx | 193 ++++++++- .../test/TableRenderKeys.test.js | 405 ++++++++++++++++++ .../test/Treeble.perf.browser.test.tsx | 213 +++++++++ .../test/Treeble.test.js | 171 +++++++- .../private/table/useLatestTableState.ts | 1 + .../src/table/useLatestTableState.ts | 103 +++++ 7 files changed, 1266 insertions(+), 11 deletions(-) create mode 100644 packages/react-aria-components/test/TableRenderKeys.test.js create mode 100644 packages/react-aria-components/test/Treeble.perf.browser.test.tsx create mode 100644 packages/react-aria/exports/private/table/useLatestTableState.ts create mode 100644 packages/react-aria/src/table/useLatestTableState.ts diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 08ddd8b0cdc..4c0f5270f4a 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -7,6 +7,7 @@ import { PressEvents, RefObject } from '@react-types/shared'; +import {AriaTableProps} from 'react-aria/useTable'; import { BaseCollection, CollectionNode, @@ -89,6 +90,7 @@ import React, { ForwardedRef, forwardRef, JSX, + memo, ReactElement, ReactNode, useCallback, @@ -112,8 +114,10 @@ import {TableColumnResizeState} from 'react-stately/useTableState'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {useCachedChildren} from 'react-aria/private/collections/useCachedChildren'; import {useControlledState} from 'react-stately/useControlledState'; +import {useDeepMemo} from 'react-aria/private/utils/useDeepMemo'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; +import {useLatestTableState} from 'react-aria/private/table/useLatestTableState'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; @@ -551,6 +555,9 @@ export const ResizableTableContainer = forwardRef(function ResizableTableContain export const TableContext = createContext>(null); export const TableStateContext = createContext | null>(null); +const TableRowRenderContext = createContext<{state: TableState; renderKey: unknown[]} | null>( + null +); export const TableColumnResizeStateContext = createContext | null>( null ); @@ -739,6 +746,27 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl ref ); let selectionManager = filteredState.selectionManager; + let latestState = useLatestTableState(filteredState); + let {onCellAction, shouldSelectOnPressUp, keyboardNavigationBehavior} = props as AriaTableProps; + // Rows and cells re-render when their own render key changes, or when this table-wide key does. + let renderKey = useDeepMemo( + [ + selectionManager.selectionMode, + selectionManager.selectionBehavior, + selectionManager.isEmpty, + filteredState.collection.size === 0, + filteredState.collection.columns.map(column => column.key).join(), + [...filteredState.collection.rowHeaderColumnKeys].join(), + filteredState.collection.headerRows.length, + filteredState.treeColumn, + !!props.onRowAction, + !!onCellAction, + shouldSelectOnPressUp, + keyboardNavigationBehavior + ], + isRenderKeyEqual + ); + let rowRenderContext = useMemo(() => ({state: latestState, renderKey}), [latestState, renderKey]); let hasDragHooks = !!dragAndDropHooks?.useDraggableCollectionState; let hasDropHooks = !!dragAndDropHooks?.useDroppableCollectionState; let dragHooksProvided = useRef(hasDragHooks); @@ -887,13 +915,18 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl } let DOMProps = filterDOMProps(props, {global: true}); + let dragAndDropContext = useMemo( + () => ({dragAndDropHooks, dragState, dropState}), + [dragAndDropHooks, dragState, dropState] + ); return ( ; }); +function isRenderKeyEqual(a: unknown[], b: unknown[]) { + return a.length === b.length && a.every((value, i) => Object.is(value, b[i])); +} + +function getLastChild(collection: ITableCollection, node: Node | null) { + let lastChildKey = (node as CollectionNode | null)?.lastChildKey; + return lastChildKey != null ? collection.getItem(lastChildKey) : null; +} + +// The table state a row renders, other than the table-wide values in TableRowRenderContext. +// Rows and cells skip rendering unless their render key changes, so any table state read while +// rendering the Row or Cell body must be included here, in getCellRenderKey, or in the table-wide +// render key in TableInner. Otherwise the row will show stale output. State read only inside event +// handlers does not need to be included, as the state reads the latest committed state by then. +function getRowRenderKey(state: TableState, item: GridNode): unknown[] { + let {collection, selectionManager} = state; + let key = item.key; + let isFocused = selectionManager.focusedKey === key; + let renderKey: unknown[] = [ + item, + selectionManager.isSelected(key), + selectionManager.isDisabled(key), + selectionManager.canSelectItem(key), + isFocused, + isFocused && selectionManager.isFocused, + isFocused ? selectionManager.childFocusStrategy : null, + state.expandedKeys.has(key), + getLastChild(collection, item)?.type + ]; + + // Updating a cell does not create a new row node. + for (let cell of collection.getChildren!(key)) { + renderKey.push(cell); + } + + // aria-setsize depends on the last sibling row. + let parent = item.parentKey != null ? collection.getItem(item.parentKey) : null; + let lastSibling = getLastChild(collection, parent); + while (lastSibling && lastSibling.type !== 'item' && lastSibling.prevKey != null) { + lastSibling = collection.getItem(lastSibling.prevKey); + } + renderKey.push(lastSibling?.index); + return renderKey; +} + +// The table state a cell renders, other than the table-wide values in TableRowRenderContext. +function getCellRenderKey(state: TableState, cell: GridNode): unknown[] { + let {collection, selectionManager} = state; + let rowKey = cell.parentKey!; + let row = collection.getItem(rowKey); + let isFocused = selectionManager.focusedKey === cell.key; + return [ + cell, + row, + getLastChild(collection, row)?.type, + selectionManager.isSelected(rowKey), + selectionManager.isDisabled(rowKey), + state.expandedKeys.has(rowKey), + selectionManager.isSelected(cell.key), + selectionManager.isDisabled(cell.key), + isFocused, + isFocused && selectionManager.isFocused, + isFocused ? selectionManager.childFocusStrategy : null + ]; +} + +type TableNodeRender = ( + props: P, + ref: ForwardedRef, + node: GridNode, + state: TableState +) => ReactElement | null; + +interface MemoizedTableNodeProps { + props: P; + forwardedRef: ForwardedRef; + node: GridNode; + state: TableState; + tableRenderKey: unknown[]; + renderKey: unknown[]; +} + +// Rows and cells only re-render when their render key changes, so that updating one row does not +// re-render the rest of the table. They render with the table state from TableRowRenderContext, +// which reads the latest committed state once its render commits, so that the event handlers of +// rows and cells that skipped later renders are never out of date. +function memoizeTableNode( + getRenderKey: (state: TableState, node: GridNode) => unknown[], + render: TableNodeRender +): (props: P, ref: ForwardedRef, node: GridNode) => ReactElement { + let Memoized = memo( + ({props, forwardedRef, node, state}: MemoizedTableNodeProps) => + render(props, forwardedRef, node, state), + (prev, next) => + prev.props === next.props && + prev.forwardedRef === next.forwardedRef && + prev.tableRenderKey === next.tableRenderKey && + isRenderKeyEqual(prev.renderKey, next.renderKey) + ); + return (props, ref, node) => { + let state = useContext(TableStateContext)!; + let {state: latestState, renderKey} = useContext(TableRowRenderContext)!; + return ( + + ); + }; +} + +function createTableBranchComponent

( + CollectionNodeClass: typeof CollectionNode, + getRenderKey: (state: TableState, node: GridNode) => unknown[], + render: TableNodeRender, + useChildren: (props: P) => ReactNode +) { + return createBranchComponent( + CollectionNodeClass, + memoizeTableNode(getRenderKey, render), + useChildren + ); +} + +function createTableLeafComponent

( + CollectionNodeClass: typeof CollectionNode, + getRenderKey: (state: TableState, node: GridNode) => unknown[], + render: TableNodeRender +) { + return createLeafComponent(CollectionNodeClass, memoizeTableNode(getRenderKey, render)); +} + /** * A row within a ``. */ -export const Row = /*#__PURE__*/ createBranchComponent( +export const Row = /*#__PURE__*/ createTableBranchComponent( TableRowNode, + getRowRenderKey, ( props: RowProps, forwardedRef: ForwardedRef, - item: GridNode + item: GridNode, + state: TableState ) => { let ref = useObjectRef(forwardedRef); - let state = useContext(TableStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); let {isVirtualized, CollectionBranch} = useContext(CollectionRendererContext); let isDraggable = @@ -1883,6 +2053,10 @@ export const Row = /*#__PURE__*/ createBranchComponent( let DOMProps = filterDOMProps(props as any, {global: true}); delete DOMProps.id; delete DOMProps.onClick; + let rowFocusContext = useMemo( + () => ({isFocusVisibleWithinRow: isFocusVisibleWithin}), + [isFocusVisibleWithin] + ); return ( <> @@ -1966,7 +2140,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( } ], [SelectionIndicatorContext, {isSelected: states.isSelected}], - [RowFocusContext, {isFocusVisibleWithinRow: isFocusVisibleWithin}] + [RowFocusContext, rowFocusContext] ]}> @@ -2115,15 +2289,16 @@ let TableCellElementType = forwardRef(function TableCellElementType( /** * A cell within a table row. */ -export const Cell = /*#__PURE__*/ createLeafComponent( +export const Cell = /*#__PURE__*/ createTableLeafComponent( TableCellNode, + getCellRenderKey, ( props: CellProps, forwardedRef: ForwardedRef, - cell: GridNode + cell: GridNode, + state: TableState ) => { let ref = useObjectRef(forwardedRef); - let state = useContext(TableStateContext)!; let {dragState} = useContext(DragAndDropContext); let {isVirtualized} = useContext(CollectionRendererContext); diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 3e41436acf9..37272e27db4 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -40,7 +40,7 @@ import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {Modal, ModalOverlay} from '../src/Modal'; import {Popover} from '../src/Popover'; import {Radio, RadioGroup} from '../src/RadioGroup'; -import React, {JSX, startTransition, Suspense, useState} from 'react'; +import React, {JSX, startTransition, Suspense, useRef, useState} from 'react'; import {Selection} from '@react-types/shared'; import styles from '../example/index.css'; import {TableLayout} from '../src/TableLayout'; @@ -1913,7 +1913,7 @@ export const TableWithReactTransition: TableStory = () => { function NameCell(props: CellProps) { return ( - ({paddingLeft: (level - 1) * 32})}> + ({paddingLeft: (level - 1) * 32})}> {({hasChildItems, isTreeColumn, isExpanded}) => ( <> {hasChildItems && isTreeColumn && ( @@ -2503,3 +2503,192 @@ export const TreeGridTableDnd: TableStory = () => {
); }; + +interface RenderCountItem { + id: string; + name: string; + status: string; + children?: RenderCountItem[]; +} + +function makeRenderCountItems(): RenderCountItem[] { + return Array.from({length: 20}, (_, w) => ({ + id: `w${w}`, + name: `Workflow ${w}`, + status: 'Running', + children: Array.from({length: 4}, (_, j) => ({ + id: `w${w}-j${j}`, + name: `Job ${w}.${j}`, + status: 'Running' + })) + })); +} + +function renderCountItem( + item: RenderCountItem, + onRender: (id: string) => void, + withDragAndDrop: boolean +): JSX.Element { + let cellProps = (column: string) => ({ + 'data-render-id': `${item.id}/${column}`, + className: () => { + onRender(`${item.id}/${column}`); + return ''; + } + }); + return ( + { + onRender(item.id); + return ''; + }}> + {withDragAndDrop && ( + + + + )} + + + + {item.name} + {item.status} + {item.children && ( + + {child => renderCountItem(child, onRender, withDragAndDrop)} + + )} + + ); +} + +// Flashes each row and cell when it renders, and reports how many rendered in the last update. +const TreeTableRenderCountsRender = (args: {withDragAndDrop: boolean}): JSX.Element => { + let [items, setItems] = useState(makeRenderCountItems); + let [, forceRender] = useState({}); + let summaryRef = useRef(null); + let tableRef = useRef(null); + let rendered = useRef(new Set()).current; + let frame = useRef(null); + + // Reports what rendered once the update has been committed. + let scheduleReport = () => { + if (frame.current != null) { + return; + } + frame.current = requestAnimationFrame(() => { + frame.current = null; + let rowCount = 0; + for (let renderedId of rendered) { + let element = tableRef.current?.querySelector(`[data-render-id="${renderedId}"]`); + element?.animate([{backgroundColor: 'gold'}, {backgroundColor: 'transparent'}], { + duration: 800 + }); + if (!renderedId.includes('/')) { + rowCount++; + } + } + if (summaryRef.current) { + let rowTotal = tableRef.current?.querySelectorAll('tbody [role="row"]').length; + summaryRef.current.textContent = `Last update rendered ${rowCount} of ${rowTotal} rows and ${rendered.size - rowCount} cells.`; + } + rendered.clear(); + }); + }; + + let onRender = (id: string) => { + rendered.add(id); + scheduleReport(); + }; + + let updateWorkflow = (id: string, update: (item: RenderCountItem) => RenderCountItem) => { + scheduleReport(); + setItems(items => items.map(item => (item.id === id ? update(item) : item))); + }; + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': String(key)})) + }); + + return ( +

+

+ Rows and cells flash when they render. Expanding a row, selecting a row, or updating one + workflow should only render the rows that changed. +

+ + + + + +

+ +

+ + + {args.withDragAndDrop && } + + + + + Name + + Status + + + {item => renderCountItem(item, onRender, args.withDragAndDrop)} + +
+
+ ); +}; + +export const TreeTableRenderCounts: StoryObj = { + render: TreeTableRenderCountsRender, + name: 'Tree Table render counts', + args: { + withDragAndDrop: false + } +}; diff --git a/packages/react-aria-components/test/TableRenderKeys.test.js b/packages/react-aria-components/test/TableRenderKeys.test.js new file mode 100644 index 00000000000..cd54fa98bb8 --- /dev/null +++ b/packages/react-aria-components/test/TableRenderKeys.test.js @@ -0,0 +1,405 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import React, {useEffect, useState} from 'react'; +import ReactDOM from 'react-dom'; +import userEvent from '@testing-library/user-event'; + +// Rows and cells skip rendering unless their render key changes. These tests run the same +// interactions against the real Table and against a copy where memo is disabled, so every row and +// cell renders every time, and check that the two always produce the same DOM. A difference means +// a render key is missing some state that the row or cell renders. + +function loadComponents() { + return { + ...require('../src/Table'), + Button: require('../src/Button').Button, + Checkbox: require('../src/Checkbox').Checkbox, + Collection: require('react-aria/Collection').Collection, + useDragAndDrop: require('../src/useDragAndDrop').useDragAndDrop + }; +} + +let MemoizedTreeTable; +let UnmemoizedTreeTable; + +// Renders every render prop into the class name, so that the DOM shows the values that rows and +// cells rendered with. +function renderPropsClassName(renderProps) { + return Object.entries(renderProps) + .filter(([key, value]) => key !== 'defaultClassName' && typeof value !== 'object') + .map(([key, value]) => `${key}-${value}`) + .join(' '); +} + +const initialState = { + items: [ + { + id: 'games', + name: 'Games', + type: 'Folder', + date: '6/7/2023', + children: [ + {id: 'mario', name: 'Mario Kart', type: 'Game', date: '8/27/1992'}, + {id: 'tetris', name: 'Tetris', type: 'Game', date: '1/27/1988'} + ] + }, + { + id: 'apps', + name: 'Applications', + type: 'Folder', + date: '4/7/2025', + children: [ + {id: 'ps', name: 'Photoshop', type: 'Application', date: '2/19/1990'}, + {id: 'premiere', name: 'Premiere', type: 'Application', date: '9/23/2003'} + ] + }, + {id: 'readme', name: 'README', type: 'File', date: '1/1/2024'} + ], + columns: ['name', 'type', 'date'], + rowHeader: 'name', + tableProps: {selectionMode: 'multiple'} +}; + +let setTableState; +let setState = update => { + act(() => setTableState(update)); +}; + +function createTreeTable(components) { + let {Table, TableHeader, Column, TableBody, Row, Cell, Button, Checkbox, Collection} = components; + let useDragAndDrop = components.useDragAndDrop; + + return function TreeTable({withDragAndDrop}) { + let [state, setStateInner] = useState(initialState); + useEffect(() => { + setTableState = setStateInner; + }, []); + let {dragAndDropHooks} = useDragAndDrop({ + getItems: keys => [...keys].map(key => ({'text/plain': String(key)})) + }); + let {items, columns, rowHeader, tableProps} = state; + + let renderRow = item => ( + + {withDragAndDrop && ( + + + + )} + + + + {columns.map(column => ( + + {({hasChildItems, isTreeColumn}) => ( + <> + {hasChildItems && isTreeColumn && ( + + )} + {item[column]} + + )} + + ))} + {item.children && {renderRow}} + + ); + + return ( + <> + + + + {withDragAndDrop && } + + + + {columns.map(column => ( + + {column} + + ))} + + 'No files'}> + {renderRow} + +
+ + + ); + }; +} + +MemoizedTreeTable = createTreeTable(loadComponents()); +jest.isolateModules(() => { + jest.doMock('react', () => ({...React, memo: Component => Component})); + jest.doMock('react-dom', () => ReactDOM); + UnmemoizedTreeTable = createTreeTable(loadComponents()); +}); + +// Serializes the DOM with generated ids replaced by their order of appearance, so that two +// renders can be compared. +function serialize(container) { + let ids = new Map(); + let replaceIds = value => + value.replace(/_r_[0-9a-z]+_|:r[0-9a-z]+:|«r[0-9a-z]+»|react-aria\d*-\d+/g, id => { + if (!ids.has(id)) { + ids.set(id, ``); + } + return ids.get(id); + }); + + let lines = []; + let visit = (node, depth) => { + let indent = ' '.repeat(depth); + if (node.nodeType === Node.TEXT_NODE) { + if (node.textContent.trim()) { + lines.push(indent + JSON.stringify(node.textContent)); + } + return; + } + let attributes = [...node.attributes] + .map(attribute => `${attribute.name}="${replaceIds(attribute.value)}"`) + .sort() + .join(' '); + let focused = node === document.activeElement ? ' (focused)' : ''; + lines.push(`${indent}<${node.tagName.toLowerCase()} ${attributes}>${focused}`); + for (let child of node.childNodes) { + visit(child, depth + 1); + } + }; + visit(container, 0); + return lines.join('\n'); +} + +let row = (container, key) => container.querySelector(`[role="row"][data-key="${key}"]`); + +function updateItem(id, update) { + let visit = items => + items.map(item => { + if (item.id === id) { + return update(item); + } + return item.children ? {...item, children: visit(item.children)} : item; + }); + setState(state => ({...state, items: visit(state.items)})); +} + +function updateTableProps(props) { + setState(state => ({...state, tableProps: {...state.tableProps, ...props}})); +} + +function removeItem(key) { + let visit = items => + items + .filter(item => item.id !== key) + .map(item => (item.children ? {...item, children: visit(item.children)} : item)); + setState(state => ({...state, items: visit(state.items)})); +} + +let focusRow = (container, key) => { + act(() => row(container, key).focus()); +}; +let checkbox = (container, key) => within(row(container, key)).getByRole('checkbox'); + +const steps = [ + ['initial render', () => {}], + ['hover a row', (user, container) => user.hover(row(container, 'games'))], + ['select a row', (user, container) => user.click(checkbox(container, 'games'))], + ['select another row', (user, container) => user.click(checkbox(container, 'readme'))], + ['focus a row', (user, container) => focusRow(container, 'games')], + ['expand with the keyboard', user => user.keyboard('{ArrowRight}')], + ['move focus down', user => user.keyboard('{ArrowDown}')], + ['focus a cell', user => user.keyboard('{ArrowRight}')], + ['move to the next cell', user => user.keyboard('{ArrowRight}')], + ['move to a cell in the next row', user => user.keyboard('{ArrowDown}')], + ['focus back on the row', user => user.keyboard('{ArrowLeft}{ArrowLeft}')], + [ + 'expand with the chevron', + (user, container) => user.click(row(container, 'apps').querySelector('[data-chevron]')) + ], + [ + 'add a child row', + () => + updateItem('games', item => ({ + ...item, + children: [...item.children, {id: 'zelda', name: 'Zelda', type: 'Game', date: '2/21/1986'}] + })) + ], + [ + 'remove the last child row', + () => updateItem('games', item => ({...item, children: item.children.slice(0, -1)})) + ], + ['change a cell', () => updateItem('tetris', item => ({...item, type: 'Puzzle'}))], + [ + 'give a row children', + () => + updateItem('readme', item => ({ + ...item, + children: [{id: 'license', name: 'LICENSE', type: 'File', date: '1/1/2024'}] + })) + ], + [ + 'expand the new parent row', + async (user, container) => { + focusRow(container, 'readme'); + await user.keyboard('{ArrowRight}'); + } + ], + ['focus a child row', (user, container) => focusRow(container, 'tetris')], + ['remove the focused row', () => removeItem('tetris')], + ['disable rows', () => updateTableProps({disabledKeys: ['mario', 'ps']})], + ['only disable selection', () => updateTableProps({disabledBehavior: 'selection'})], + ['add a row action', () => updateTableProps({onRowAction: () => {}})], + [ + 'clear the selection', + async (user, container) => { + focusRow(container, 'apps'); + await user.keyboard('{Escape}'); + } + ], + ['select a row again', (user, container) => user.click(checkbox(container, 'apps'))], + ['add a cell action', () => updateTableProps({onCellAction: () => {}})], + [ + 'press a cell with a cell action', + (user, container) => user.click(within(row(container, 'premiere')).getByText('Premiere')) + ], + ['remove the cell action', () => updateTableProps({onCellAction: undefined})], + ['select on press up', () => updateTableProps({shouldSelectOnPressUp: true})], + [ + 'press a row without releasing', + (user, container) => + user.pointer({ + keys: '[MouseLeft>]', + target: within(row(container, 'premiere')).getByText('Premiere') + }) + ], + ['release the press', user => user.pointer({keys: '[/MouseLeft]'})], + ['use replace selection', () => updateTableProps({selectionBehavior: 'replace'})], + [ + 'click a row with replace selection', + (user, container) => user.click(within(row(container, 'readme')).getByText('README')) + ], + ['disallow empty selection', () => updateTableProps({disallowEmptySelection: true})], + [ + 'try to deselect the only selected row', + (user, container) => user.click(checkbox(container, 'readme')) + ], + ['use tab keyboard navigation', () => updateTableProps({keyboardNavigationBehavior: 'tab'})], + [ + 'navigate cells with tab navigation', + async (user, container) => { + focusRow(container, 'readme'); + await user.keyboard('{ArrowRight}{ArrowRight}'); + } + ], + ['tab within a row', user => user.tab()], + [ + 'press an arrow key inside a cell with tab navigation', + async (user, container) => { + act(() => checkbox(container, 'apps').focus()); + await user.keyboard('{ArrowRight}'); + } + ], + ['use arrow keyboard navigation', () => updateTableProps({keyboardNavigationBehavior: 'arrow'})], + [ + 'navigate cells with arrow navigation', + async (user, container) => { + focusRow(container, 'readme'); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowDown}'); + } + ], + [ + 'press an arrow key inside a cell with arrow navigation', + async (user, container) => { + act(() => checkbox(container, 'apps').focus()); + await user.keyboard('{ArrowRight}'); + } + ], + ['use toggle selection', () => updateTableProps({selectionBehavior: 'toggle'})], + ['allow empty selection', () => updateTableProps({disallowEmptySelection: false})], + ['use single selection', () => updateTableProps({selectionMode: 'single'})], + [ + 'select a row with single selection', + (user, container) => user.click(checkbox(container, 'premiere')) + ], + ['add a column', () => setState(state => ({...state, columns: [...state.columns, 'id']}))], + [ + 'remove a column', + () => setState(state => ({...state, columns: state.columns.filter(c => c !== 'date')})) + ], + ['change the tree column', () => updateTableProps({treeColumn: 'type'})], + ['change the tree column back', () => updateTableProps({treeColumn: 'name'})], + ['change the row header column', () => setState(state => ({...state, rowHeader: 'type'}))], + ['change the row header column back', () => setState(state => ({...state, rowHeader: 'name'}))], + ['tab out of the table', user => user.tab()], + ['tab back into the table', user => user.tab({shift: true})], + [ + 'collapse a row', + async (user, container) => { + focusRow(container, 'games'); + await user.keyboard('{ArrowLeft}'); + } + ], + ['enable rows', () => updateTableProps({disabledKeys: []})], + ['disable selection', () => updateTableProps({selectionMode: 'none'})], + ['empty the table', () => setState(state => ({...state, items: []}))], + ['refill the table', () => setState(state => ({...state, items: initialState.items}))] +]; + +async function runSteps(TreeTable, withDragAndDrop) { + let user = userEvent.setup({delay: null, pointerMap}); + let {container, unmount} = render(); + let snapshots = []; + for (let [name, step] of steps) { + await step(user, container); + act(() => jest.runAllTimers()); + snapshots.push([name, serialize(container)]); + } + unmount(); + return snapshots; +} + +describe('Table render keys', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it.each([false, true])( + 'renders the same as a table without memoization (drag and drop: %s)', + async withDragAndDrop => { + let expected = await runSteps(UnmemoizedTreeTable, withDragAndDrop); + let actual = await runSteps(MemoizedTreeTable, withDragAndDrop); + for (let i = 0; i < steps.length; i++) { + expect({step: actual[i][0], dom: actual[i][1]}).toEqual({ + step: expected[i][0], + dom: expected[i][1] + }); + } + } + ); +}); diff --git a/packages/react-aria-components/test/Treeble.perf.browser.test.tsx b/packages/react-aria-components/test/Treeble.perf.browser.test.tsx new file mode 100644 index 00000000000..5b89f7e26f4 --- /dev/null +++ b/packages/react-aria-components/test/Treeble.perf.browser.test.tsx @@ -0,0 +1,213 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Cell, Column, Row, Table, TableBody, TableHeader} from '../src/Table'; +import {Collection} from 'react-aria/Collection'; +import {createRoot, Root} from 'react-dom/client'; +import {describe, expect, it} from 'vitest'; +import {flushSync} from 'react-dom'; +import {Key} from '@react-types/shared'; +import React, {Profiler, useState} from 'react'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; + +// A tree table shaped like a CI dashboard: 50 workflows with 5 jobs each (300 rows when expanded). +interface Item { + id: string; + name: string; + children?: Item[]; +} + +const PARENTS = 50; +const CHILDREN = 5; + +function makeItems(): Item[] { + return Array.from({length: PARENTS}, (_, p) => ({ + id: `w${p}`, + name: `Workflow ${p}`, + children: Array.from({length: CHILDREN}, (_, c) => ({id: `w${p}-j${c}`, name: `Job ${p}.${c}`})) + })); +} + +let counts = {row: 0, cell: 0, commits: 0}; +function resetCounts() { + counts = {row: 0, cell: 0, commits: 0}; +} + +// Lets the tests update the table's state from outside React. +let controls: { + setItems: (fn: (items: Item[]) => Item[]) => void; + setExpandedKeys: (keys: Set) => void; +}; + +function TreeTable({initialExpanded}: {initialExpanded: Set}) { + let [items, setItems] = useState(makeItems); + let [expandedKeys, setExpandedKeys] = useState>(initialExpanded); + useLayoutEffect(() => { + controls = {setItems, setExpandedKeys}; + }, []); + let renderRow = (item: Item) => ( + { + counts.row++; + return 'row'; + }}> + { + counts.cell++; + return 'cell'; + }}> + {item.name} + + { + counts.cell++; + return 'cell'; + }}> + Running + + { + counts.cell++; + return 'cell'; + }}> + 1m 2s + + {item.children && {renderRow}} + + ); + + return ( + counts.commits++}> + + + + Name + + Status + Duration + + {renderRow} +
+
+ ); +} + +function mount(initialExpanded: Set): {root: Root; container: HTMLElement} { + let container = document.createElement('div'); + document.body.appendChild(container); + let root = createRoot(container); + flushSync(() => root.render()); + // Warm up the JIT so the first measurement is comparable with the rest. + for (let i = 0; i < 5; i++) { + flushSync(() => controls.setItems(items => [...items])); + } + return {root, container}; +} + +function measure(fn: () => void) { + resetCounts(); + let start = performance.now(); + flushSync(fn); + let time = performance.now() - start; + return {time, ...counts}; +} + +function median(values: number[]) { + let sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +const allExpanded = () => new Set(Array.from({length: PARENTS}, (_, p) => `w${p}`)); + +function addChildren(parent: string, count: number, round: number) { + controls.setItems(items => + items.map(item => + item.id === parent + ? { + ...item, + children: [ + ...item.children!, + ...Array.from({length: count}, (_, i) => ({ + id: `${parent}-extra${round}-${i}`, + name: `Extra ${round}.${i}` + })) + ] + } + : item + ) + ); +} + +describe('Tree Table render cost', () => { + it('only re-renders the changed rows when one row gains child rows', async () => { + let {root, container} = mount(allExpanded()); + expect(container.querySelectorAll('[role="row"]').length).toBe(1 + PARENTS * (CHILDREN + 1)); + + let run = (rounds: number, fn: (round: number) => void) => { + let times: number[] = []; + let result; + for (let round = 0; round < rounds; round++) { + result = measure(() => fn(round)); + times.push(result.time); + } + return {...result, median: median(times)}; + }; + + let addOne = run(7, round => addChildren('w10', 1, round)); + console.log('[bench] add 1 child row:', JSON.stringify(addOne)); + let noChange = run(7, () => controls.setItems(items => [...items])); + console.log('[bench] re-render, no changes:', JSON.stringify(noChange)); + let addSixty = run(5, round => addChildren('w20', 60, round)); + console.log('[bench] add 60 child rows:', JSON.stringify(addSixty)); + + expect(container.querySelectorAll('[role="row"]').length).toBe( + 1 + PARENTS * (CHILDREN + 1) + 7 + 300 + ); + // The parent, its previous last child, the new row, and its siblings (whose aria-setsize changes). + expect(addOne.row).toBeLessThanOrEqual(CHILDREN + 7 + 1); + expect(noChange.row).toBe(0); + expect(noChange.cell).toBe(0); + + root.unmount(); + container.remove(); + }); + + it('only re-renders the toggled row and its children when expanding a row', async () => { + let collapsed = allExpanded(); + collapsed.delete('w30'); + let {root, container} = mount(collapsed); + + let times: number[] = []; + let expand; + let collapse; + for (let round = 0; round < 7; round++) { + expand = measure(() => controls.setExpandedKeys(allExpanded())); + collapse = measure(() => controls.setExpandedKeys(collapsed)); + times.push(expand.time); + } + console.log('[bench] expand one row:', JSON.stringify({median: median(times), ...expand})); + console.log('[bench] collapse one row:', JSON.stringify(collapse)); + expect(container.querySelectorAll('[role="row"]').length).toBe( + 1 + PARENTS * (CHILDREN + 1) - CHILDREN + ); + expect(expand.row).toBeLessThanOrEqual(CHILDREN + 2); + + root.unmount(); + container.remove(); + }); +}); diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.js index 38c4eaf318f..e7e88ec79cc 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.js @@ -16,7 +16,7 @@ import {Button} from '../src/Button'; import {Collection} from 'react-aria/Collection'; import {composeRenderProps} from '../src/utils'; import {I18nProvider} from 'react-aria/I18nProvider'; -import React from 'react'; +import React, {createContext, useContext, useState} from 'react'; import {useDragAndDrop} from '../src/useDragAndDrop'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -712,5 +712,174 @@ describe('Treeble', () => { await user.keyboard('{ArrowUp}'); act(() => jest.runAllTimers()); expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Documents'); + + // End the drag so the rest of the document is no longer hidden for later tests. + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + }); +}); + +describe('Treeble row memoization', () => { + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + + let initialItems = [ + {id: 'a', name: 'A', children: [{id: 'a1', name: 'A1'}]}, + {id: 'b', name: 'B', children: [{id: 'b1', name: 'B1'}]}, + {id: 'c', name: 'C'}, + {id: 'd', name: 'D'} + ]; + + function DynamicTreeble({onRender, onAction, ...props}) { + let [items, setItems] = useState(initialItems); + let [count, setCount] = useState(0); + let renderRow = item => ( + { + onRender?.(id); + return 'row'; + }}> + {item.name} + {item.children ? 'Folder' : 'File'} + {item.children && {renderRow}} + + ); + + return ( + <> + + + onAction(key, count) : undefined} + {...props}> + + + Name + + Type + + {renderRow} +
+ + ); + } + + function getRow(tree, name) { + return tree + .getAllByRole('rowheader') + .find(cell => cell.textContent.replace('>', '') === name) + .closest('[role="row"]'); + } + + it('does not re-render rows that did not change', async () => { + let rendered = new Set(); + let tree = render( rendered.add(id)} />); + expect(tree.getAllByRole('row')).toHaveLength(7); + + rendered.clear(); + await user.click(tree.getByRole('button', {name: 'Increment'})); + expect([...rendered]).toEqual([]); + + // The parent, its previous last child, and the new row. + await user.click(tree.getByRole('button', {name: 'Add child'})); + expect(tree.getAllByRole('row')).toHaveLength(8); + expect([...rendered].sort()).toEqual(['b', 'b1', 'b2']); + expect(getRow(tree, 'B2')).toHaveAttribute('aria-posinset', '2'); + expect(getRow(tree, 'B1')).toHaveAttribute('aria-setsize', '2'); + + rendered.clear(); + await user.click(within(getRow(tree, 'A')).getByRole('button')); + expect(tree.getAllByRole('row')).toHaveLength(7); + expect([...rendered]).toEqual(['a']); + }); + + it('rows that did not re-render use the latest selection', async () => { + let onSelectionChange = jest.fn(); + let tree = render( + + ); + + await user.click(getRow(tree, 'A')); + await user.click(getRow(tree, 'C')); + await user.click(getRow(tree, 'D')); + expect(new Set(onSelectionChange.mock.lastCall[0])).toEqual(new Set(['a', 'c', 'd'])); + + // Range selection from a row that did not re-render must include rows added since. + await user.click(getRow(tree, 'A1')); + await user.click(tree.getByRole('button', {name: 'Add child'})); + await user.keyboard('{Shift>}'); + await user.click(getRow(tree, 'C')); + await user.keyboard('{/Shift}'); + expect(new Set(onSelectionChange.mock.lastCall[0])).toEqual( + new Set(['a', 'a1', 'b', 'b1', 'b2', 'c', 'd']) + ); + expect(getRow(tree, 'B2')).toHaveAttribute('aria-selected', 'true'); + }); + + it('rows that did not re-render call the latest onRowAction', async () => { + let onAction = jest.fn(); + let tree = render(); + + await user.click(tree.getByRole('button', {name: 'Increment'})); + await user.click(tree.getByRole('button', {name: 'Increment'})); + await user.click(getRow(tree, 'D')); + expect(onAction).toHaveBeenLastCalledWith('d', 2); + }); + + it('updates a cell when only the cell changes', async () => { + let LabelContext = createContext('Initial'); + function LabelCell() { + return {useContext(LabelContext)}; + } + + function Example() { + let [label, setLabel] = useState('Initial'); + return ( + <> + + + + + + Name + + Label + + + + A + + + +
+
+ + ); + } + + let tree = render(); + expect(tree.getAllByRole('gridcell')[0]).toHaveTextContent('Initial'); + await user.click(tree.getByRole('button', {name: 'Update'})); + expect(tree.getAllByRole('gridcell')[0]).toHaveTextContent('Updated'); }); }); diff --git a/packages/react-aria/exports/private/table/useLatestTableState.ts b/packages/react-aria/exports/private/table/useLatestTableState.ts new file mode 100644 index 00000000000..f527c6ea26b --- /dev/null +++ b/packages/react-aria/exports/private/table/useLatestTableState.ts @@ -0,0 +1 @@ +export {useLatestTableState} from '../../../src/table/useLatestTableState'; diff --git a/packages/react-aria/src/table/useLatestTableState.ts b/packages/react-aria/src/table/useLatestTableState.ts new file mode 100644 index 00000000000..e499fb6346d --- /dev/null +++ b/packages/react-aria/src/table/useLatestTableState.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {gridIds} from './utils'; +import {gridMap} from '../grid/utils'; +import {RefObject, useMemo, useRef} from 'react'; +import {TableState} from 'react-stately/useTableState'; +import {useLayoutEffect} from '../utils/useLayoutEffect'; + +/** + * Creates an object that forwards all property access to the value returned by `getTarget`. + * Getters and methods run with the proxy as `this`, so class instances such as a SelectionManager + * read the target's internal state too. + */ +function createForwardingProxy( + getTarget: () => T, + overrides: Partial> = {} +): T { + return new Proxy({} as T, { + get(_, key, receiver) { + if (Object.prototype.hasOwnProperty.call(overrides, key)) { + return overrides[key as string]; + } + return Reflect.get(getTarget(), key, receiver); + }, + set(_, key, value) { + return Reflect.set(getTarget(), key, value); + }, + has(_, key) { + return Reflect.has(getTarget(), key); + }, + ownKeys() { + return Reflect.ownKeys(getTarget()); + }, + getOwnPropertyDescriptor(_, key) { + let descriptor = Reflect.getOwnPropertyDescriptor(getTarget(), key); + return descriptor ? {...descriptor, configurable: true} : undefined; + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(getTarget()); + } + }); +} + +// The table state for one render, which reads the latest committed state once that render commits. +class LatestTableState { + state: TableState; + private renderState: TableState; + private committedState: RefObject> | null = null; + + constructor(renderState: TableState) { + this.renderState = renderState; + let getState = () => this.getState(); + let getGrid = () => gridMap.get(getState())!; + let selectionManager = createForwardingProxy(() => getState().selectionManager); + this.state = createForwardingProxy(getState, {selectionManager}); + gridMap.set( + this.state, + createForwardingProxy(getGrid, { + keyboardDelegate: createForwardingProxy(() => getGrid().keyboardDelegate), + actions: createForwardingProxy(() => getGrid().actions) + }) + ); + gridIds.set(this.state, gridIds.get(renderState)!); + } + + private getState() { + return this.committedState ? this.committedState.current : this.renderState; + } + + commit(committedState: RefObject>) { + this.committedState = committedState; + } +} + +/** + * Returns a table state object that rows and cells can keep using after they skip re-rendering. + * Until this render commits, it reads the state passed in, so rendering only sees this render's + * state. Once committed, it reads the latest committed state instead, so event handlers from rows + * that skipped later renders never act on an outdated collection, selection, or callbacks. The + * state passed in must already have been registered with `useTable`. + * + * @private + */ +export function useLatestTableState(state: TableState): TableState { + let committedState = useRef>(state as TableState); + let latest = useMemo(() => new LatestTableState(state as TableState), [state]); + useLayoutEffect(() => { + committedState.current = state as TableState; + latest.commit(committedState); + }, [state, latest]); + + return latest.state as TableState; +}