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; +}