From 7fb1eea47bff4b5d19c90abcdf118e68b3b6ec8a Mon Sep 17 00:00:00 2001 From: Dmitrii Kamenskikh Date: Thu, 25 Jun 2026 17:14:17 +0100 Subject: [PATCH 1/4] feat(table): apply column widths via CSS variables during resize Update column widths imperatively on the table root during drag so cells read from CSS custom properties instead of inline layout styles. Commit widths on resize end with a single relayout, and flush onResize when the drag finishes. Co-authored-by: Cursor  Conflicts:  packages/react-aria/src/table/useTableColumnResize.ts --- .../react-spectrum/src/table/Resizer.tsx | 11 +- .../src/table/TableViewBase.tsx | 217 ++++++++++-------- packages/react-aria-components/src/Table.tsx | 33 ++- .../react-aria-components/src/Virtualizer.tsx | 14 +- .../src/table/useTableColumnResize.ts | 72 +++++- .../src/virtualizer/VirtualizerItem.tsx | 51 +++- packages/react-stately/exports/index.ts | 8 + .../react-stately/exports/useTableState.ts | 8 + .../react-stately/src/layout/TableLayout.ts | 3 +- .../react-stately/src/table/columnWidthCSS.ts | 108 +++++++++ .../src/table/useTableColumnResizeState.ts | 144 ++++++++++-- 11 files changed, 529 insertions(+), 140 deletions(-) create mode 100644 packages/react-stately/src/table/columnWidthCSS.ts diff --git a/packages/@adobe/react-spectrum/src/table/Resizer.tsx b/packages/@adobe/react-spectrum/src/table/Resizer.tsx index c3af2c5d954..209f61af5da 100644 --- a/packages/@adobe/react-spectrum/src/table/Resizer.tsx +++ b/packages/@adobe/react-spectrum/src/table/Resizer.tsx @@ -15,7 +15,7 @@ import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useTableColumnResize} from 'react-aria/useTable'; -import {useTableContext, useVirtualizerContext} from './TableViewBase'; +import {useTableContext} from './TableViewBase'; import {useUNSAFE_PortalContext} from 'react-aria/PortalProvider'; // @ts-ignore import wCursor from 'bundle-text:./cursors/Cur_MoveToLeft_9_9.svg'; @@ -54,19 +54,16 @@ export const Resizer = React.forwardRef(function Resizer( ) { let {column, showResizer} = props; let objectRef = useObjectRef(ref); - let {isEmpty, onFocusedResizer} = useTableContext(); + let {isEmpty, onFocusedResizer, columnWidthRootRef} = useTableContext(); let layout = useContext(ResizeStateContext)!; - // Virtualizer re-renders, but these components are all cached - // in order to get around that and cause a rerender here, we use context - // but we don't actually need any value, they are available on the layout object - useVirtualizerContext(); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/table'); let {direction} = useLocale(); let {inputProps, resizerProps, isMouseResizing} = useTableColumnResize( mergeProps(props, { 'aria-label': stringFormatter.format('columnResizer'), - isDisabled: isEmpty + isDisabled: isEmpty, + columnWidthRootRef }), layout, objectRef diff --git a/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx b/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx index 1841f41b6d7..8905d02c85f 100644 --- a/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx +++ b/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx @@ -161,6 +161,7 @@ export interface TableContextValue { headerMenuOpen: boolean; setHeaderMenuOpen: (val: boolean) => void; renderEmptyState?: () => ReactElement; + columnWidthRootRef: RefObject; } export const TableContext = React.createContext | null>(null); @@ -168,14 +169,30 @@ export function useTableContext(): TableContextValue { return useContext(TableContext)!; } -export const VirtualizerContext = React.createContext<{width: number; key: Key | null} | null>( - null -); -export function useVirtualizerContext(): { - width: number; - key: Key | null; -} | null { - return useContext(VirtualizerContext); +function getColumnLayoutIndex(node: GridNode | null | undefined): number | undefined { + if (!node) { + return undefined; + } + return node.colIndex ?? node.index; +} + +function getColumnResizeIndicatorPosition( + resizingColumnKey: Key | null, + columns: GridNode[], + getColumnWidth: (key: Key) => number +): number { + if (resizingColumnKey == null) { + return 0; + } + + let position = 0; + for (let column of columns) { + position += getColumnWidth(column.key); + if (column.key === resizingColumnKey) { + return position - 2; + } + } + return 0; } interface TableBaseProps extends SpectrumTableProps { @@ -370,7 +387,12 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef + parent={parent!} + columnIndex={getColumnLayoutIndex(reusableView.content)} + colSpan={reusableView.content?.colSpan ?? 1} + useColumnCSSVariables={ + reusableView.layoutInfo?.type === 'column' || reusableView.layoutInfo?.type === 'cell' + }> {reusableView.rendered} ); @@ -525,7 +547,8 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef (props: TableVirtualizerProps) { [scale] ); + let onWidthsApplied = useCallback( + (totalWidth: number) => { + let width = `${totalWidth}px`; + let bodyContent = bodyRef.current?.firstElementChild; + if (bodyContent instanceof HTMLElement) { + bodyContent.style.width = width; + } + let headerContent = headerRef.current?.firstElementChild; + if (headerContent instanceof HTMLElement) { + headerContent.style.width = width; + } + }, + [bodyRef, headerRef] + ); + let columnResizeState = useTableColumnResizeState( { tableWidth, getDefaultWidth, - getDefaultMinWidth + getDefaultMinWidth, + columnWidthRootRef: domRef, + onWidthsApplied }, tableState ); @@ -737,32 +777,22 @@ function TableVirtualizer(props: TableVirtualizerProps) { } }, [bodyRef, headerRef]); - let resizerPosition = - columnResizeState.resizingColumn != null - ? layout.getLayoutInfo(columnResizeState.resizingColumn)!.rect.maxX - 2 - : 0; + let resizerPosition = getColumnResizeIndicatorPosition( + columnResizeState.resizingColumn, + collection.columns, + columnResizeState.getColumnWidth + ); + + let totalTableWidth = collection.columns.reduce( + (sum, column) => sum + columnResizeState.getColumnWidth(column.key), + 0 + ); let resizerAtEdge = - resizerPosition > - Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; - // this should be fine, every movement of the resizer causes a rerender - // scrolling can cause it to lag for a moment, but it's always updated + resizerPosition > Math.max(totalTableWidth, state.virtualizer.visibleRect.width) - 3; let resizerInVisibleRegion = resizerPosition < state.virtualizer.visibleRect.maxX; let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; - // minimize re-render caused on Resizers by memoing this - let resizingColumnWidth = - columnResizeState.resizingColumn != null - ? columnResizeState.getColumnWidth(columnResizeState.resizingColumn) - : 0; - let resizingColumn = useMemo( - () => ({ - width: resizingColumnWidth, - key: columnResizeState.resizingColumn - }), - [resizingColumnWidth, columnResizeState.resizingColumn] - ); - if (isVirtualDragging) { otherProps.tabIndex = undefined; } @@ -776,66 +806,64 @@ function TableVirtualizer(props: TableVirtualizerProps) { let visibleViews = renderChildren(null, state.visibleViews, renderWrapper); return ( - - -
+ +
+
+ + {visibleViews[0]} + +
+ + {visibleViews[1]}
- - {visibleViews[0]} - -
- - {visibleViews[1]} -
- -
- - + /> +
+
+
); } @@ -1601,12 +1629,18 @@ function TableCellWrapper({ layoutInfo, virtualizer, parent, - children + children, + columnIndex, + colSpan = 1, + useColumnCSSVariables = false }: { layoutInfo: LayoutInfo; virtualizer: any; parent: ReusableView; children: ReactNode; + columnIndex?: number; + colSpan?: number; + useColumnCSSVariables?: boolean; }) { let {isTableDroppable, dropState} = useContext(TableContext)!; let isDropTarget = false; @@ -1627,6 +1661,9 @@ function TableCellWrapper({ layoutInfo={layoutInfo} virtualizer={virtualizer} parent={parent?.layoutInfo} + useColumnCSSVariables={useColumnCSSVariables} + columnIndex={columnIndex} + colSpan={colSpan} className={useMemo( () => classNames( diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 45eb1d488bf..c393aca202e 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -43,7 +43,7 @@ import { DefaultCollectionRenderer, ItemRenderProps } from './Collection'; -import {ColumnSize, ColumnStaticSize} from 'react-stately/useTableState'; +import {ColumnSize, ColumnStaticSize, getColumnWidthVarName} from 'react-stately/useTableState'; import { DisabledBehavior, Node, @@ -448,6 +448,7 @@ interface ResizableTableContainerContextValue { tableWidth: number; tableRef: RefObject; scrollRef: RefObject; + columnWidthRootRef: RefObject; // Dependency inject useTableColumnResizeState so it doesn't affect bundle size unless you're using ResizableTableContainer. useTableColumnResizeState: typeof useTableColumnResizeState; onResizeStart?: (widths: Map) => void; @@ -523,6 +524,7 @@ export const ResizableTableContainer = forwardRef(function ResizableTableContain () => ({ tableRef, scrollRef, + columnWidthRootRef: containerRef, tableWidth: width, // oxlint-disable-next-line react/react-compiler useTableColumnResizeState, @@ -530,7 +532,7 @@ export const ResizableTableContainer = forwardRef(function ResizableTableContain onResize: props.onResize, onResizeEnd: props.onResizeEnd }), - [tableRef, width, props.onResizeStart, props.onResize, props.onResizeEnd] + [tableRef, containerRef, width, props.onResizeStart, props.onResize, props.onResizeEnd] ); return ( @@ -680,6 +682,19 @@ let TableElementType = forwardRef(function TableElementType( return ; }); +function TableColGroup() { + let state = useContext(TableStateContext)!; + let collection = state.collection as TableCollection; + + return ( + + {collection.columns.map(column => ( + + ))} + + ); +} + const EXPANSION_KEYS = { expand: { ltr: 'ArrowRight', @@ -871,7 +886,8 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl // oxlint-disable-next-line react/react-compiler layoutState = tableContainerContext.useTableColumnResizeState( { - tableWidth: tableContainerContext.tableWidth + tableWidth: tableContainerContext.tableWidth, + columnWidthRootRef: tableContainerContext.columnWidthRootRef }, filteredState ); @@ -915,6 +931,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl data-drop-target={isRootDropTarget || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined}> + {!isVirtualized && layoutState ? : null} (null); let {resizerProps, inputProps, isResizing, isMouseResizing} = useTableColumnResize( @@ -1381,7 +1397,8 @@ export const ColumnResizer = forwardRef(function ColumnResizer( onResizeStart, onResize, onResizeEnd, - triggerRef + triggerRef, + columnWidthRootRef }, layoutState, inputRef diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index b870bd94d76..701e6ee5182 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -18,6 +18,7 @@ import { renderAfterDropIndicators } from './Collection'; import {DropTargetDelegate, ItemDropTarget, Node} from '@react-types/shared'; +import {GridNode} from 'react-stately/private/grid/GridCollection'; import { Layout, ReusableView, @@ -162,12 +163,21 @@ function renderWrapper( reusableView: View, renderDropIndicator?: (target: ItemDropTarget) => ReactNode ): ReactNode { + let layoutInfo = reusableView.layoutInfo!; + let useColumnCSSVariables = layoutInfo.type === 'column' || layoutInfo.type === 'cell'; + let gridNode = reusableView.content as GridNode | null | undefined; + let columnIndex = gridNode != null ? (gridNode.colIndex ?? gridNode.index) : undefined; + let colSpan = gridNode?.colSpan ?? 1; + let rendered = ( + parent={parent?.layoutInfo} + useColumnCSSVariables={useColumnCSSVariables} + columnIndex={columnIndex} + colSpan={colSpan}> {reusableView.rendered} ); diff --git a/packages/react-aria/src/table/useTableColumnResize.ts b/packages/react-aria/src/table/useTableColumnResize.ts index e3c1ae89334..b51c0c7deb7 100644 --- a/packages/react-aria/src/table/useTableColumnResize.ts +++ b/packages/react-aria/src/table/useTableColumnResize.ts @@ -60,6 +60,8 @@ export interface AriaTableColumnResizeProps { * resizer and not on focus. */ triggerRef?: RefObject; + /** Ref to the table root element for applying column width CSS custom properties. */ + columnWidthRootRef?: RefObject; /** If resizing is disabled. */ isDisabled?: boolean; /** Called when resizing starts. */ @@ -86,6 +88,7 @@ export function useTableColumnResize( let { column: item, triggerRef, + columnWidthRootRef, isDisabled, onResizeStart, onResize, @@ -102,29 +105,82 @@ export function useTableColumnResize( // Whether a mouse drag-resize is active. Set on the first move (not on press) so a cursor // overlay only mounts during an actual drag. let [isMouseResizing, setMouseResizing] = useState(false); + let pendingOnResize = useRef | null>(null); + let onResizeFrame = useRef(null); let {direction} = useLocale(); + let applyWidthsToDOM = useCallback( + (resizingColumnKey?: Key) => { + let root = columnWidthRootRef?.current; + if (root) { + state.applyToDOM(root, resizingColumnKey ?? state.resizingColumn); + } + }, + [columnWidthRootRef, state] + ); + + let updateResizeInput = useCallback( + (width: number) => { + if (ref.current) { + let value = String(Math.floor(width)); + ref.current.value = value; + ref.current.setAttribute('value', value); + } + }, + [ref] + ); + + let flushOnResize = useCallback(() => { + if (pendingOnResize.current) { + onResize?.(pendingOnResize.current); + pendingOnResize.current = null; + } + onResizeFrame.current = null; + }, [onResize]); + + let scheduleOnResize = useCallback( + (sizes: Map) => { + pendingOnResize.current = sizes; + if (onResizeFrame.current == null) { + onResizeFrame.current = requestAnimationFrame(flushOnResize); + } + }, + [flushOnResize] + ); + + useEffect(() => { + return () => { + if (onResizeFrame.current != null) { + cancelAnimationFrame(onResizeFrame.current); + } + }; + }, []); + let startResize = useCallback( item => { if (!isResizingRef.current) { - lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); state.startResize(item.key); + lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); state.tableState.setKeyboardNavigationDisabled(true); + applyWidthsToDOM(item.key); + updateResizeInput(state.getColumnWidth(item.key)); onResizeStart?.(lastSize.current); } isResizingRef.current = true; }, - [state, onResizeStart] + [state, onResizeStart, applyWidthsToDOM, updateResizeInput] ); let resize = useCallback( (item, newWidth) => { let sizes = state.updateResizedColumns(item.key, newWidth); - onResize?.(sizes); + applyWidthsToDOM(item.key); + updateResizeInput(state.getColumnWidth(item.key)); + scheduleOnResize(sizes); lastSize.current = sizes; }, - [state, onResize] + [state, applyWidthsToDOM, updateResizeInput, scheduleOnResize] ); let endResize = useCallback( @@ -134,6 +190,12 @@ export function useTableColumnResize( lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); } + if (onResizeFrame.current != null) { + cancelAnimationFrame(onResizeFrame.current); + } + flushOnResize(); + + applyWidthsToDOM(); state.endResize(); state.tableState.setKeyboardNavigationDisabled(false); onResizeEnd?.(lastSize.current); @@ -146,7 +208,7 @@ export function useTableColumnResize( } lastSize.current = null; }, - [state, triggerRef, onResizeEnd] + [state, triggerRef, onResizeEnd, applyWidthsToDOM, flushOnResize] ); let endResizeEvent = () => { diff --git a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx index c5533cf7b31..57b936f0fed 100644 --- a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx +++ b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx @@ -11,6 +11,7 @@ */ import {Direction} from '@react-types/shared'; +import {getColumnHorizontalStyle} from 'react-stately/useTableState'; import {LayoutInfo} from 'react-stately/useVirtualizerState'; import React, {CSSProperties, JSX, ReactNode, useRef} from 'react'; import {useLocale} from '../i18n/I18nProvider'; @@ -22,10 +23,24 @@ interface VirtualizerItemProps extends Omit { style?: CSSProperties; className?: string; children: ReactNode; + /** When true, horizontal positioning uses CSS custom properties instead of inline styles. */ + useColumnCSSVariables?: boolean; + columnIndex?: number; + colSpan?: number; } export function VirtualizerItem(props: VirtualizerItemProps): JSX.Element { - let {style, className, layoutInfo, virtualizer, parent, children} = props; + let { + style, + className, + layoutInfo, + virtualizer, + parent, + children, + useColumnCSSVariables, + columnIndex, + colSpan + } = props; let {direction} = useLocale(); let ref = useRef(null); useVirtualizerItem({ @@ -34,12 +49,22 @@ export function VirtualizerItem(props: VirtualizerItemProps): JSX.Element { ref }); + let columnStyle = + useColumnCSSVariables && columnIndex != null + ? getColumnHorizontalStyle(columnIndex, colSpan ?? 1) + : undefined; + return (
+ data-column-index={columnIndex} + style={{ + ...layoutInfoToStyle(layoutInfo, direction, parent, useColumnCSSVariables), + ...columnStyle, + ...style + }}> {children}
); @@ -49,8 +74,11 @@ let cache = new WeakMap(); export function layoutInfoToStyle( layoutInfo: LayoutInfo, dir: Direction, - parent?: LayoutInfo | null + parent?: LayoutInfo | null, + useColumnCSSVariables?: boolean ): CSSProperties { + let usesColumnCSSVars = + useColumnCSSVariables && (layoutInfo.type === 'column' || layoutInfo.type === 'cell'); let xProperty = dir === 'rtl' ? 'right' : 'left'; let cached = cache.get(layoutInfo); if (cached && cached[xProperty] != null) { @@ -60,8 +88,8 @@ export function layoutInfoToStyle( // Invalidate if the parent position changed. let top = layoutInfo.rect.y - parent.rect.y; - let x = layoutInfo.rect.x - parent.rect.x; - if (cached.top === top && cached[xProperty] === x) { + let x = usesColumnCSSVars ? undefined : layoutInfo.rect.x - parent.rect.x; + if (cached.top === top && (usesColumnCSSVars || cached[xProperty] === x)) { return cached; } } @@ -74,13 +102,16 @@ export function layoutInfoToStyle( top: layoutInfo.rect.y - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.y : 0), - [xProperty]: - layoutInfo.rect.x - - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.x : 0), - width: layoutInfo.rect.width, height: layoutInfo.rect.height }; + if (!usesColumnCSSVars) { + rectStyles[xProperty] = + layoutInfo.rect.x - + (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.x : 0); + rectStyles.width = layoutInfo.rect.width; + } + // Get rid of any non finite values since they aren't valid css values Object.entries(rectStyles).forEach(([key, value]) => { if (!Number.isFinite(value)) { @@ -96,7 +127,7 @@ export function layoutInfoToStyle( opacity: layoutInfo.opacity, zIndex: layoutInfo.zIndex, transform: layoutInfo.transform ?? undefined, - contain: 'size layout style', + contain: usesColumnCSSVars ? 'layout style' : 'size layout style', ...rectStyles }; diff --git a/packages/react-stately/exports/index.ts b/packages/react-stately/exports/index.ts index e6a7f818cd6..f91ab67ede8 100644 --- a/packages/react-stately/exports/index.ts +++ b/packages/react-stately/exports/index.ts @@ -214,6 +214,14 @@ export {Column} from '../src/table/Column'; export {Row} from '../src/table/Row'; export {Cell} from '../src/table/Cell'; export {useTableColumnResizeState} from '../src/table/useTableColumnResizeState'; +export { + applyColumnWidthsToDOM, + getColumnHorizontalStyle, + getColumnStartVarName, + getColumnWidthVarName, + columnWidthsEqual +} from '../src/table/columnWidthCSS'; +export type {ColumnWidthEntry} from '../src/table/columnWidthCSS'; export {useTabListState} from '../src/tabs/useTabListState'; export {useToastState, ToastQueue, useToastQueue} from '../src/toast/useToastState'; export {useToggleState} from '../src/toggle/useToggleState'; diff --git a/packages/react-stately/exports/useTableState.ts b/packages/react-stately/exports/useTableState.ts index b1817f7ed30..c35cd2cb8bc 100644 --- a/packages/react-stately/exports/useTableState.ts +++ b/packages/react-stately/exports/useTableState.ts @@ -17,6 +17,14 @@ export type { export type {TableProps, TableState, TableStateProps} from '../src/table/useTableState'; export {useTableColumnResizeState} from '../src/table/useTableColumnResizeState'; +export { + applyColumnWidthsToDOM, + getColumnHorizontalStyle, + getColumnStartVarName, + getColumnWidthVarName, + columnWidthsEqual +} from '../src/table/columnWidthCSS'; +export type {ColumnWidthEntry} from '../src/table/columnWidthCSS'; export {useTableState, UNSTABLE_useFilteredTableState} from '../src/table/useTableState'; export type {CellProps, CellElement, CellRenderer} from '../src/table/Cell'; diff --git a/packages/react-stately/src/layout/TableLayout.ts b/packages/react-stately/src/layout/TableLayout.ts index e8d75a8021b..5d9c7fa2679 100644 --- a/packages/react-stately/src/layout/TableLayout.ts +++ b/packages/react-stately/src/layout/TableLayout.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {columnWidthsEqual} from '../table/columnWidthCSS'; import {DropTarget, ItemDropTarget, Key} from '@react-types/shared'; import {getChildNodes} from '../collections/getChildNodes'; import {GridNode} from '../grid/GridCollection'; @@ -128,7 +129,7 @@ export class TableLayout exten shouldInvalidateLayoutOptions(newOptions: O, oldOptions: O): boolean { return ( - newOptions.columnWidths !== oldOptions.columnWidths || + !columnWidthsEqual(newOptions.columnWidths, oldOptions.columnWidths) || super.shouldInvalidateLayoutOptions(newOptions, oldOptions) ); } diff --git a/packages/react-stately/src/table/columnWidthCSS.ts b/packages/react-stately/src/table/columnWidthCSS.ts new file mode 100644 index 00000000000..b4615ea68fa --- /dev/null +++ b/packages/react-stately/src/table/columnWidthCSS.ts @@ -0,0 +1,108 @@ +/* + * 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 {CSSProperties} from 'react'; +import {Key} from '@react-types/shared'; + +export function getColumnWidthVarName(index: number): string { + return `--col-${index}-width`; +} + +export function getColumnStartVarName(index: number): string { + return `--col-${index}-start`; +} + +export interface ColumnWidthEntry { + key: Key; + index: number; +} + +/** + * Applies column width CSS custom properties to a table root element. + * Returns the total table content width. + */ +export function applyColumnWidthsToDOM( + root: HTMLElement, + columns: ColumnWidthEntry[], + columnWidths: Map, + resizingColumnKey?: Key | null +): number { + let start = 0; + let totalWidth = 0; + + for (let column of columns) { + let width = columnWidths.get(column.key) ?? 0; + root.style.setProperty(getColumnWidthVarName(column.index), `${width}px`); + root.style.setProperty(getColumnStartVarName(column.index), `${start}px`); + start += width; + totalWidth += width; + } + + root.style.setProperty('--table-total-width', `${totalWidth}px`); + + if (resizingColumnKey != null) { + let indicatorPosition = 0; + for (let column of columns) { + indicatorPosition += columnWidths.get(column.key) ?? 0; + if (column.key === resizingColumnKey) { + root.style.setProperty('--resize-indicator-position', `${indicatorPosition - 2}px`); + break; + } + } + } + + return totalWidth; +} + +/** + * Returns mount-time styles for a column or cell wrapper that reads horizontal + * positioning from CSS custom properties on an ancestor. + */ +export function getColumnHorizontalStyle(columnIndex: number, colSpan: number = 1): CSSProperties { + if (colSpan <= 1) { + return { + width: `var(${getColumnWidthVarName(columnIndex)})`, + insetInlineStart: `var(${getColumnStartVarName(columnIndex)})` + }; + } + + let widthParts: string[] = []; + for (let i = 0; i < colSpan; i++) { + widthParts.push(`var(${getColumnWidthVarName(columnIndex + i)})`); + } + + return { + width: `calc(${widthParts.join(' + ')})`, + insetInlineStart: `var(${getColumnStartVarName(columnIndex)})` + }; +} + +export function columnWidthsEqual( + a: Map | undefined, + b: Map | undefined +): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + if (a.size !== b.size) { + return false; + } + for (let [key, val] of a) { + if (b.get(key) !== val) { + return false; + } + } + return true; +} diff --git a/packages/react-stately/src/table/useTableColumnResizeState.ts b/packages/react-stately/src/table/useTableColumnResizeState.ts index 0eb3a513c55..2e294439d69 100644 --- a/packages/react-stately/src/table/useTableColumnResizeState.ts +++ b/packages/react-stately/src/table/useTableColumnResizeState.ts @@ -10,12 +10,29 @@ * governing permissions and limitations under the License. */ +import {applyColumnWidthsToDOM, ColumnWidthEntry, columnWidthsEqual} from './columnWidthCSS'; import {ColumnSize} from './Column'; import {GridNode} from '../grid/GridCollection'; -import {Key} from '@react-types/shared'; +import {Key, RefObject} from '@react-types/shared'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import {TableColumnLayout} from './TableColumnLayout'; import {TableState} from './useTableState'; -import {useCallback, useMemo, useState} from 'react'; + +const useLayoutEffect: typeof React.useLayoutEffect = + typeof document !== 'undefined' ? React.useLayoutEffect : () => {}; + +function buildPixelWidths( + layout: TableColumnLayout, + tableWidth: number, + collection: TableState['collection'], + sizes: Map +): Map { + let snapshot = new TableColumnLayout({ + getDefaultWidth: layout.getDefaultWidth.bind(layout), + getDefaultMinWidth: layout.getDefaultMinWidth.bind(layout) + }); + return snapshot.buildColumnWidths(tableWidth, collection, sizes); +} export interface TableColumnResizeStateProps { /** @@ -27,6 +44,16 @@ export interface TableColumnResizeStateProps { getDefaultWidth?: (node: GridNode) => ColumnSize | null | undefined; /** A function that is called to find the default minWidth for a given column. */ getDefaultMinWidth?: (node: GridNode) => ColumnSize | null | undefined; + /** + * Ref to the table root element where column width CSS custom properties + * should be applied. + */ + columnWidthRootRef?: RefObject; + /** + * Called after column widths are applied to the DOM. Can be used to update + * scroll container sizes without a React re-render. + */ + onWidthsApplied?: (totalWidth: number) => void; } export interface TableColumnResizeState { @@ -49,8 +76,10 @@ export interface TableColumnResizeState { resizingColumn: Key | null; /** A reference to the table state. */ tableState: TableState; - /** A map of the current column widths. */ + /** A map of the current committed column widths. */ columnWidths: Map; + /** Applies column width CSS custom properties to the table root element. */ + applyToDOM: (root: HTMLElement, resizingColumnKey?: Key | null) => void; } /** @@ -66,9 +95,19 @@ export function useTableColumnResizeState( props: TableColumnResizeStateProps, state: TableState ): TableColumnResizeState { - let {getDefaultWidth, getDefaultMinWidth, tableWidth = 0} = props; + let { + getDefaultWidth, + getDefaultMinWidth, + tableWidth = 0, + columnWidthRootRef, + onWidthsApplied + } = props; let [resizingColumn, setResizingColumn] = useState(null); + let isResizingRef = useRef(false); + let pendingUncontrolledWidthsRef = useRef>(new Map()); + let pendingSizesRef = useRef | null>(null); + let columnLayout = useMemo( () => new TableColumnLayout({ @@ -101,6 +140,11 @@ export function useTableColumnResizeState( setLastColumns(state.collection.columns); } + let columnEntries: ColumnWidthEntry[] = useMemo( + () => state.collection.columns.map(column => ({key: column.key, index: column.index})), + [state.collection.columns] + ); + // combine columns back into one map that maintains same order as the columns let colWidths = useMemo( () => @@ -119,37 +163,101 @@ export function useTableColumnResizeState( ] ); + let columnWidths = useMemo(() => { + let sizes = colWidths; + + if (pendingSizesRef.current != null) { + if (isResizingRef.current) { + sizes = pendingSizesRef.current; + } else if ( + !columnWidthsEqual( + buildPixelWidths(columnLayout, tableWidth, state.collection, colWidths), + buildPixelWidths(columnLayout, tableWidth, state.collection, pendingSizesRef.current) + ) + ) { + sizes = pendingSizesRef.current; + } else { + pendingSizesRef.current = null; + } + } + + return columnLayout.buildColumnWidths(tableWidth, state.collection, sizes); + }, [tableWidth, state.collection, colWidths, columnLayout]); + + let applyToDOM = useCallback( + (root: HTMLElement, activeResizingColumn?: Key | null) => { + let totalWidth = applyColumnWidthsToDOM( + root, + columnEntries, + columnLayout.columnWidths, + activeResizingColumn ?? resizingColumn + ); + onWidthsApplied?.(totalWidth); + }, + [columnEntries, columnLayout, resizingColumn, onWidthsApplied] + ); + + // Sync CSS variables when committed widths or table width change. + useLayoutEffect(() => { + if (columnWidthRootRef?.current && !isResizingRef.current) { + applyToDOM(columnWidthRootRef.current); + } + }, [columnWidths, columnWidthRootRef, applyToDOM, tableWidth]); + + // Rebuild widths when the table is resized during an active column resize. + useLayoutEffect(() => { + if (isResizingRef.current && columnWidthRootRef?.current && pendingSizesRef.current) { + columnLayout.buildColumnWidths(tableWidth, state.collection, pendingSizesRef.current); + applyToDOM(columnWidthRootRef.current); + } + }, [tableWidth, columnWidthRootRef, columnLayout, state.collection, applyToDOM]); + let startResize = useCallback( (key: Key) => { + isResizingRef.current = true; + pendingUncontrolledWidthsRef.current = uncontrolledWidths; setResizingColumn(key); }, - [setResizingColumn] + [uncontrolledWidths] ); let updateResizedColumns = useCallback( (key: Key, width: number): Map => { + let currentUncontrolled = isResizingRef.current + ? pendingUncontrolledWidthsRef.current + : uncontrolledWidths; + let newSizes = columnLayout.resizeColumnWidth( state.collection, - uncontrolledWidths, + currentUncontrolled, key, width ); - let map = new Map(Array.from(uncontrolledColumns).map(([key]) => [key, newSizes.get(key)!])); + let map = new Map( + Array.from(uncontrolledColumns).map(([colKey]) => [colKey, newSizes.get(colKey)!]) + ); map.set(key, width); - setUncontrolledWidths(map); + pendingSizesRef.current = newSizes; + + if (isResizingRef.current) { + pendingUncontrolledWidthsRef.current = map; + columnLayout.buildColumnWidths(tableWidth, state.collection, newSizes); + } else { + setUncontrolledWidths(map); + } + return newSizes; }, - [uncontrolledColumns, setUncontrolledWidths, columnLayout, state.collection, uncontrolledWidths] + [uncontrolledColumns, columnLayout, state.collection, uncontrolledWidths, tableWidth] ); let endResize = useCallback(() => { + if (isResizingRef.current) { + setUncontrolledWidths(pendingUncontrolledWidthsRef.current); + isResizingRef.current = false; + } setResizingColumn(null); - }, [setResizingColumn]); - - let columnWidths = useMemo( - () => columnLayout.buildColumnWidths(tableWidth, state.collection, colWidths), - [tableWidth, state.collection, colWidths, columnLayout] - ); + }, []); return useMemo( () => ({ @@ -161,7 +269,8 @@ export function useTableColumnResizeState( getColumnMinWidth: (key: Key) => columnLayout.getColumnMinWidth(key), getColumnMaxWidth: (key: Key) => columnLayout.getColumnMaxWidth(key), tableState: state, - columnWidths + columnWidths, + applyToDOM }), [ columnLayout, @@ -170,7 +279,8 @@ export function useTableColumnResizeState( updateResizedColumns, startResize, endResize, - state + state, + applyToDOM ] ); } From 88573e0afa54c8e668d42114674b6aaeb934c485 Mon Sep 17 00:00:00 2001 From: Dmitrii Kamenskikh Date: Thu, 25 Jun 2026 17:14:21 +0100 Subject: [PATCH 2/4] test(table): read column widths from CSS variables in resize tests Add shared test utilities for CSS variable column widths and update table resizing tests to assert pixel widths without relying on inline style.width. Co-authored-by: Cursor --- .../test/table/TableSizing.test.tsx | 248 +++++++++--------- .../test/table/columnWidthTestUtils.ts | 63 +++++ .../test/table/tableResizingTests.tsx | 13 +- 3 files changed, 192 insertions(+), 132 deletions(-) create mode 100644 packages/react-aria/test/table/columnWidthTestUtils.ts diff --git a/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx b/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx index 4a6d36632c0..a7e4c2b003f 100644 --- a/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx +++ b/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx @@ -24,6 +24,7 @@ import { simulateDesktop, triggerTouch } from '@react-spectrum/test-utils-internal'; +import {getCellWidth} from '../../../../react-aria/test/table/columnWidthTestUtils'; import {HidingColumns} from '../../stories/table/HidingColumns'; import {Key} from '@react-types/shared'; import {Provider} from '../../src/provider/Provider'; @@ -401,10 +402,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('321px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('320px'); - expect((row.childNodes[3] as HTMLElement).style.width).toBe('321px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(38); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(321); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(320); + expect(getCellWidth(row.childNodes[3] as HTMLElement)).toBe(321); } }); @@ -422,10 +423,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('48px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('317px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('318px'); - expect((row.childNodes[3] as HTMLElement).style.width).toBe('317px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(48); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(317); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(318); + expect(getCellWidth(row.childNodes[3] as HTMLElement)).toBe(317); } }); @@ -452,9 +453,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('500px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('300px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(500); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(300); } }); @@ -477,10 +478,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('381px'); - expect((row.childNodes[3] as HTMLElement).style.width).toBe('381px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(38); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(381); + expect(getCellWidth(row.childNodes[3] as HTMLElement)).toBe(381); } }); @@ -505,9 +506,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('100px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('500px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('400px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(100); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(500); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(400); } }); @@ -532,10 +533,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); - expect((row.childNodes[3] as HTMLElement).style.width).toBe('262px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(38); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(500); + expect(getCellWidth(row.childNodes[3] as HTMLElement)).toBe(262); } }); @@ -560,10 +561,10 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); - expect((row.childNodes[3] as HTMLElement).style.width).toBe('262px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(38); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(500); + expect(getCellWidth(row.childNodes[3] as HTMLElement)).toBe(262); } }); @@ -588,9 +589,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('300px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(300); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(500); } }); @@ -615,9 +616,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('300px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('500px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(300); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(500); } }); @@ -645,9 +646,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } }); }); @@ -676,9 +677,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('300px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('500px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(300); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(500); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } }); }); @@ -697,21 +698,21 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); - expect((rows[0].childNodes[0] as HTMLElement).style.width).toBe('230px'); - expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('770px'); + expect(getCellWidth(rows[0].childNodes[0] as HTMLElement)).toBe(230); + expect(getCellWidth(rows[0].childNodes[1] as HTMLElement)).toBe(770); - expect((rows[1].childNodes[0] as HTMLElement).style.width).toBe('230px'); - expect((rows[1].childNodes[1] as HTMLElement).style.width).toBe('385px'); - expect((rows[1].childNodes[2] as HTMLElement).style.width).toBe('193px'); - expect((rows[1].childNodes[3] as HTMLElement).style.width).toBe('192px'); + expect(getCellWidth(rows[1].childNodes[0] as HTMLElement)).toBe(230); + expect(getCellWidth(rows[1].childNodes[1] as HTMLElement)).toBe(385); + expect(getCellWidth(rows[1].childNodes[2] as HTMLElement)).toBe(193); + expect(getCellWidth(rows[1].childNodes[3] as HTMLElement)).toBe(192); for (let row of rows.slice(2)) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('38px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('192px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('193px'); - expect((row.childNodes[3] as HTMLElement).style.width).toBe('192px'); - expect((row.childNodes[4] as HTMLElement).style.width).toBe('193px'); - expect((row.childNodes[5] as HTMLElement).style.width).toBe('192px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(38); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(192); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(193); + expect(getCellWidth(row.childNodes[3] as HTMLElement)).toBe(192); + expect(getCellWidth(row.childNodes[4] as HTMLElement)).toBe(193); + expect(getCellWidth(row.childNodes[5] as HTMLElement)).toBe(192); } }); }); @@ -750,9 +751,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } let resizableHeader = tree.getAllByRole('columnheader')[0]; @@ -770,9 +771,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(595); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } expect(onResizeEnd).toHaveBeenCalledTimes(1); expect(onResizeEnd).toHaveBeenCalledWith( @@ -790,9 +791,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(620); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(190); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(190); } expect(onResizeEnd).toHaveBeenCalledTimes(2); expect(onResizeEnd).toHaveBeenCalledWith( @@ -840,9 +841,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } let resizableHeader = tree.getAllByRole('columnheader')[0]; @@ -860,9 +861,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(595); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } expect(onResizeEnd).toHaveBeenCalledTimes(1); expect(onResizeEnd).toHaveBeenCalledWith( @@ -880,9 +881,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(620); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(190); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(190); } expect(onResizeEnd).toHaveBeenCalledTimes(2); expect(onResizeEnd).toHaveBeenCalledWith( @@ -939,9 +940,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } let header = tree.getAllByRole('columnheader')[0]; @@ -969,9 +970,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(595); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } // actual locations do not matter, the delta matters between events for the calculation of useMove @@ -981,9 +982,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(620); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(190); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(190); } // tapping on the document.body doesn't cause a blur in jest because the body isn't focusable, so just call blur @@ -1037,9 +1038,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } let header = tree.getAllByRole('columnheader')[0]; @@ -1071,9 +1072,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '595'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('595px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(595); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } // actual locations do not matter, the delta matters between events for the calculation of useMove @@ -1083,9 +1084,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(620); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(190); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(190); } // tapping on the document.body doesn't cause a blur in jest because the body isn't focusable, so just call blur @@ -1174,9 +1175,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } await user.keyboard('{Enter}'); @@ -1202,27 +1203,27 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(620); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(190); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(190); } await user.keyboard('{ArrowLeft}'); await user.keyboard('{ArrowLeft}'); expect(resizer).toHaveAttribute('value', '600'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } await user.keyboard('{ArrowUp}'); await user.keyboard('{ArrowUp}'); expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(620); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(190); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(190); } await user.keyboard('{ArrowDown}'); @@ -1230,9 +1231,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '600'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } await user.keyboard('{Escape}'); @@ -1283,9 +1284,9 @@ describe('TableViewSizing', function () { let rows = tree.getAllByRole('row'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } fireEvent.keyDown(document.activeElement!, {key: 'Enter'}); @@ -1311,9 +1312,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '620'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('620px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('190px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('190px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(620); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(190); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(190); } fireEvent.keyDown(document.activeElement!, {key: 'ArrowLeft'}); @@ -1323,9 +1324,9 @@ describe('TableViewSizing', function () { expect(resizer).toHaveAttribute('value', '600'); for (let row of rows) { - expect((row.childNodes[0] as HTMLElement).style.width).toBe('600px'); - expect((row.childNodes[1] as HTMLElement).style.width).toBe('200px'); - expect((row.childNodes[2] as HTMLElement).style.width).toBe('200px'); + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + expect(getCellWidth(row.childNodes[1] as HTMLElement)).toBe(200); + expect(getCellWidth(row.childNodes[2] as HTMLElement)).toBe(200); } fireEvent.keyDown(document.activeElement!, {key: 'Escape'}); @@ -1637,9 +1638,9 @@ describe('TableViewSizing', function () { it('should update the row widths when removing and adding columns', async function () { function compareWidths(row, b) { - let newWidth = row.childNodes[1].style.width; - expect(parseInt(newWidth, 10)).toBeGreaterThan(parseInt(b, 10)); - return newWidth; + let newWidth = getCellWidth(row.childNodes[1] as HTMLElement); + expect(newWidth).toBeGreaterThan(parseInt(b, 10)); + return String(newWidth); } let tree = render(); @@ -1651,7 +1652,7 @@ describe('TableViewSizing', function () { expect(columns).toHaveLength(6); let rows = tree.getAllByRole('row'); - let oldWidth = (rows[1].childNodes[1] as HTMLElement).style.width; + let oldWidth = String(getCellWidth(rows[1].childNodes[1] as HTMLElement)); let audienceCheckbox = tree.getByLabelText('Audience Type') as HTMLInputElement; let budgetCheckbox = tree.getByLabelText('Net Budget') as HTMLInputElement; @@ -1696,7 +1697,7 @@ describe('TableViewSizing', function () { act(() => { jest.runAllTimers(); }); - expect(parseInt((rows[1].childNodes[1] as HTMLElement).style.width, 10)).toBeLessThan( + expect(getCellWidth(rows[1].childNodes[1] as HTMLElement)).toBeLessThan( parseInt(oldWidth, 10) ); }); @@ -1749,14 +1750,14 @@ describe('TableViewSizing', function () { expect(headers[0]).toHaveTextContent('Foo'); // visually hidden syle expect((headers[1].childNodes[0] as HTMLElement).style.clipPath).toBe('inset(50%)'); - expect((headers[1].childNodes[0] as HTMLElement).style.width).toBe('1px'); + expect(getCellWidth(headers[1].childNodes[0] as HTMLElement)).toBe(1); expect((headers[1].childNodes[0] as HTMLElement).style.height).toBe('1px'); expect(headers[1]).not.toBeEmptyDOMElement(); let rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(1); // The width of headerless column - expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('38px'); + expect(getCellWidth(rows[0].childNodes[1] as HTMLElement)).toBe(38); let rowheader = within(rows[0]).getByRole('rowheader'); expect(rowheader).toHaveTextContent('Foo 1'); let actionCell = within(rows[0]).getAllByRole('gridcell'); @@ -1777,7 +1778,7 @@ describe('TableViewSizing', function () { let rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(1); // The width of headerless column - expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('46px'); + expect(getCellWidth(rows[0].childNodes[1] as HTMLElement)).toBe(46); }); it('renders table with headerless column and divider', function () { @@ -1789,7 +1790,7 @@ describe('TableViewSizing', function () { let rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(1); // The width of headerless column with divider - expect((rows[0].childNodes[1] as HTMLElement).style.width).toBe('39px'); + expect(getCellWidth(rows[0].childNodes[1] as HTMLElement)).toBe(39); }); it('renders table with headerless column with tooltip', async function () { @@ -1832,17 +1833,8 @@ function resizeCol(tree, col, delta) { // actual locations do not matter, the delta matters between events for the calculation of useMove fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 0, pageY: 30}); - act(() => { - jest.runAllTimers(); - }); fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: delta, pageY: 25}); - act(() => { - jest.runAllTimers(); - }); fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); - act(() => { - jest.runAllTimers(); - }); } function resizeTable(clientWidth, newValue) { diff --git a/packages/react-aria/test/table/columnWidthTestUtils.ts b/packages/react-aria/test/table/columnWidthTestUtils.ts new file mode 100644 index 00000000000..2fad4add616 --- /dev/null +++ b/packages/react-aria/test/table/columnWidthTestUtils.ts @@ -0,0 +1,63 @@ +/* + * 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. + */ + +export function findColumnWidthRoot(element: HTMLElement): HTMLElement | null { + let el: HTMLElement | null = element; + while (el) { + if (el.style.getPropertyValue('--col-0-width')) { + return el; + } + el = el.parentElement; + } + return null; +} + +export function getColumnWidthFromCSSVar(index: number, root: HTMLElement): number { + let value = root.style.getPropertyValue(`--col-${index}-width`); + return Number(value.replace('px', '').trim()); +} + +export function getCellWidth(cell: HTMLElement): number { + let styleWidth = cell.style.width; + if (styleWidth.endsWith('px')) { + return Number(styleWidth.replace('px', '')); + } + + let root = findColumnWidthRoot(cell); + if (!root) { + return Number(styleWidth.replace('px', '')) || 0; + } + + if (styleWidth.startsWith('calc(')) { + let sum = 0; + for (let match of styleWidth.matchAll(/var\(--col-(\d+)-width\)/g)) { + sum += getColumnWidthFromCSSVar(Number(match[1]), root); + } + return sum; + } + + let columnIndex = cell.getAttribute('data-column-index'); + if (columnIndex != null) { + return getColumnWidthFromCSSVar(Number(columnIndex), root); + } + + return 0; +} + +export function getColumnWidthsFromRow(headerRow: HTMLElement): number[] { + let root = findColumnWidthRoot(headerRow); + if (root) { + return Array.from(headerRow.children).map((_, index) => getColumnWidthFromCSSVar(index, root)); + } + + return Array.from(headerRow.children).map(cell => getCellWidth(cell as HTMLElement)); +} diff --git a/packages/react-aria/test/table/tableResizingTests.tsx b/packages/react-aria/test/table/tableResizingTests.tsx index baa57fc4271..23bd77bc550 100644 --- a/packages/react-aria/test/table/tableResizingTests.tsx +++ b/packages/react-aria/test/table/tableResizingTests.tsx @@ -11,6 +11,7 @@ */ import {act, installPointerEvent} from '@react-spectrum/test-utils-internal'; +import {getColumnWidthsFromRow} from './columnWidthTestUtils'; import React from 'react'; @@ -31,9 +32,7 @@ let rows = [ function getColumnWidths(tree) { let rows = tree.getAllByRole('row') as HTMLElement[]; - return Array.from(rows[0].children).map(cell => - Number((cell as HTMLElement).style.width.replace('px', '')) - ); + return getColumnWidthsFromRow(rows[0]); } export let resizingTests = ( @@ -41,9 +40,15 @@ export let resizingTests = ( rerender: any, Table: any, ControlledTable: any, - resizeCol: any, + resizeColImpl: any, resizeTable: any ): void => { + let resizeCol = (tree: any, col: any, delta: any) => { + resizeColImpl(tree, col, delta); + act(() => { + jest.runAllTimers(); + }); + }; // assumption with all these tests // 1. the controlling values we pass in aren't actually controlling // the sizes, they are instead more like the default values that the controlling logic uses From 4b711bb8529db1832dcc0ebc1de894648ce3a6cc Mon Sep 17 00:00:00 2001 From: Dmitrii Kamenskikh Date: Thu, 23 Jul 2026 11:03:01 +0100 Subject: [PATCH 3/4] docs(rfc): add RFC for table column resize via CSS variables --- .../README.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 rfcs/2026-table-column-resize-css-vars/README.md diff --git a/rfcs/2026-table-column-resize-css-vars/README.md b/rfcs/2026-table-column-resize-css-vars/README.md new file mode 100644 index 00000000000..06941c1c096 --- /dev/null +++ b/rfcs/2026-table-column-resize-css-vars/README.md @@ -0,0 +1,111 @@ + + +- Start Date: 2026-07-23 +- RFC PR: (leave this empty, to be filled in later) +- Authors: Dmitrii Kamenskikh, coolassassin + +# Table column resize via CSS custom properties + +## Summary + +During column drag-resize, table column widths are applied imperatively as CSS custom properties on the table root (`--col-N-width`, `--col-N-start`, `--table-total-width`). Cells and columns read horizontal size from those variables instead of inline `width`/`left` from the virtualizer layout. React state and virtualizer relayout run once on `endResize`, not on every pointer move. + +## Motivation + +Today, each pointer move during column resize calls `updateResizedColumns`, which updates React state (`setUncontrolledWidths`). That triggers a virtualizer relayout and re-renders all visible cells on every frame. On large virtualized tables this causes visible jank. + +The goal is smooth column resizing without React relayout on every pointer move. Column widths should be committed to React state (and the virtualizer) only when the drag ends. The public resize API (`onResize`, `onResizeEnd`, keyboard resize) should remain unchanged. + +Proof-of-concept implementation: https://github.com/adobe/react-spectrum/pull/10267 + +## Detailed Design + +### Layer responsibilities + +| Layer | Responsibility | +|-------|----------------| +| **react-stately** | Pure state: `columnWidths`, `pendingSizesRef` during resize, `columnWidthsEqual`, pure helpers for CSS variable names and `CSSProperties` (`getColumnWidthVarName`, `getColumnHorizontalStyle`) | +| **react-aria** | DOM: `applyColumnWidthsToDOM`, sync in `useTableColumnResize`, `useSyncColumnWidthCSSVars` for commit-time synchronization | +| **Table packages** (Spectrum `TableViewBase`, RAC `Table`) | Table-specific cell positioning via wrappers, not generic Virtualizer | +| **Virtualizer / VirtualizerItem** | Unchanged generic API | + +### Resize flow + +1. **Resize start** (`startResize`): set `isResizingRef = true`, snapshot uncontrolled widths into a ref, set `resizingColumn`. +2. **During drag** (`updateResizedColumns`): compute new sizes in `pendingSizesRef`, update `TableColumnLayout.columnWidths` in memory, imperatively write CSS vars to the table root. No `setState` for uncontrolled widths. +3. **During drag** (`onResize`): throttled via `requestAnimationFrame` so consumers still receive width updates without blocking the main thread. +4. **Resize end** (`endResize`): commit `pendingUncontrolledWidthsRef` via `setUncontrolledWidths`, clear resize refs, trigger one virtualizer relayout with final `columnWidths`. +5. **CSS variables on root**: + - `--col-{index}-width`: pixel width of column N + - `--col-{index}-start`: cumulative inline-start offset of column N + - `--table-total-width`: sum of all column widths + - `--resize-indicator-position`: inline position of the resize indicator during drag +6. **Cell/column positioning**: for `layoutInfo.type === 'column' | 'cell'`, horizontal `width` and `insetInlineStart` come from CSS variables. Vertical positioning (`top`, `height`) still comes from the virtualizer layout. + +### Table-specific virtualizer styling + +Generic `Virtualizer` and `VirtualizerItem` must not contain table-specific logic. Instead: + +- **Spectrum**: `TableCellWrapper` in `TableViewBase` computes styles via `getTableVirtualizerItemStyle` and passes them to `VirtualizerItem` through the `style` prop. +- **RAC**: `Virtualizer` accepts an optional generic `renderItem` callback. Virtualized table stories/tests pass a table-aware renderer that applies `getTableVirtualizerItemStyle`. + +### Window resize during active column resize + +When the table viewport width changes during an active resize, `useSyncColumnWidthCSSVars` rebuilds pixel widths from `pendingSizesRef` and re-applies CSS variables without committing React state. + +## Documentation + +No new public API is introduced. Internal CSS variable names are implementation details. No formal announcement is needed; this is a performance improvement invisible to most users. + +## Drawbacks + +- **Dual source of truth during drag**: pending ref + CSS variables. Requires careful synchronization on viewport resize and resize end. +- **More imperative code**: DOM writes in react-aria add complexity compared to the fully declarative current approach. +- **Table-specific wrappers**: virtualized RAC tables need a `renderItem` override or equivalent table-specific integration. +- **Testing**: tests must read widths from CSS variables rather than inline `style.width`. + +## Backwards Compatibility Analysis + +- Public resize callbacks (`onResizeStart`, `onResize`, `onResizeEnd`) and keyboard/mouse resize behavior are unchanged. +- Column width props (`width`, `defaultWidth`, `minWidth`, `maxWidth`) are unchanged. +- Internal cell positioning switches from inline `width`/`left` to CSS variables during resize. This is not observable to consumers who do not depend on inline styles. +- `onResize` continues to fire during drag (throttled to animation frames), preserving controlled-width use cases. + +## Alternatives + +1. **Current approach (state update on every move)** — simpler architecture, but poor performance on large virtualized tables. +2. **`flushSync` / forced synchronous relayout** — would reduce frame delay but increase main-thread blocking; worse overall performance. +3. **CSS Grid for column layout** — would require a fundamental change to table virtualizer positioning; large breaking change. +4. **CSS variables set via React inline `style` on root** — still triggers React re-renders on every move if state drives the style object. + +## Open Questions + +1. Should CSS variable name helpers (`getColumnWidthVarName`, etc.) be publicly exported, or kept internal? +2. For RAC virtualized tables, is a generic `renderItem` prop on `Virtualizer` acceptable, or should we ship a dedicated `TableVirtualizer` component? +3. Should `onResize` continue firing during drag, or only on `endResize`? Current design keeps `onResize` for controlled-width scenarios. + +## Help Needed + +The authors can implement this RFC. Feedback from the core team on layer boundaries (especially Virtualizer extension point) is appreciated before merge. + +## Frequently Asked Questions + +**Q: Does this change how consumers set column widths?** +A: No. `width`/`defaultWidth`/`minWidth`/`maxWidth` on columns and `ResizableTableContainer` callbacks work the same. + +**Q: Will this work with RTL?** +A: Yes. Horizontal positioning uses `insetInlineStart`, which respects direction. + +**Q: Does keyboard resize still work?** +A: Yes. Keyboard moves use the same code path; CSS vars update on each key step, state commits on end. + +## Related Discussions + +- Implementation PR: https://github.com/adobe/react-spectrum/pull/10267 From 4e0cc8b7b3034205d447c5d4f7ecd5572936a1f7 Mon Sep 17 00:00:00 2001 From: Dmitrii Kamenskikh Date: Thu, 23 Jul 2026 11:03:15 +0100 Subject: [PATCH 4/4] =?UTF-8?q?refactor(table):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20RFC,=20layer=20split,=20generic=20Virtualizer=20Mov?= =?UTF-8?q?e=20DOM=20sync=20from=20stately=20to=20react-aria.=20Revert=20t?= =?UTF-8?q?able-specific=20logic=20from=20Virtualizer/VirtualizerItem;=20u?= =?UTF-8?q?se=20table=20wrappers=20and=20renderItem.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/table/TableViewBase.tsx | 66 ++++++---- .../test/table/TableSizing.test.tsx | 48 +++++++ .../s2/test/TableView.test.tsx | 37 ++++++ .../exports/Virtualizer.ts | 3 +- .../react-aria-components/exports/index.ts | 3 +- packages/react-aria-components/src/Table.tsx | 11 +- .../react-aria-components/src/TableLayout.ts | 6 + .../src/TableVirtualizerItem.tsx | 53 ++++++++ .../react-aria-components/src/Virtualizer.tsx | 117 +++++++++++------ .../stories/Table.stories.tsx | 2 +- .../react-aria-components/test/Table.test.js | 123 ++++++++++++++++++ .../exports/private/table/columnWidthDOM.ts | 2 + .../private/table/tableVirtualizerStyle.ts | 1 + .../react-aria/src/table/columnWidthDOM.ts | 106 +++++++++++++++ .../src/table/tableVirtualizerStyle.ts | 47 +++++++ .../src/table/useTableColumnResize.ts | 8 +- .../src/virtualizer/VirtualizerItem.tsx | 53 ++------ packages/react-stately/exports/index.ts | 1 - .../react-stately/exports/useTableState.ts | 1 - .../react-stately/src/table/columnWidthCSS.ts | 37 ------ .../src/table/useTableColumnResizeState.ts | 80 ++++-------- 21 files changed, 604 insertions(+), 201 deletions(-) create mode 100644 packages/react-aria-components/src/TableVirtualizerItem.tsx create mode 100644 packages/react-aria/exports/private/table/columnWidthDOM.ts create mode 100644 packages/react-aria/exports/private/table/tableVirtualizerStyle.ts create mode 100644 packages/react-aria/src/table/columnWidthDOM.ts create mode 100644 packages/react-aria/src/table/tableVirtualizerStyle.ts diff --git a/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx b/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx index 8905d02c85f..93216025ef3 100644 --- a/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx +++ b/packages/@adobe/react-spectrum/src/table/TableViewBase.tsx @@ -37,6 +37,7 @@ import { getInteractionModality, isFocusVisible } from 'react-aria/private/interactions/useFocusVisible'; +import {getTableVirtualizerItemStyle} from 'react-aria/private/table/tableVirtualizerStyle'; import {GridNode} from 'react-stately/private/grid/GridCollection'; import {HoverProps, useHover} from 'react-aria/useHover'; import {InsertionIndicator} from './InsertionIndicator'; @@ -89,6 +90,7 @@ import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatte import {usePress} from 'react-aria/usePress'; import {useProvider, useProviderProps} from '../provider/Provider'; import {useStyleProps} from '../utils/styleProps'; +import {useSyncColumnWidthCSSVars} from 'react-aria/private/table/columnWidthDOM'; import { useTable, useTableCell, @@ -389,10 +391,7 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef + colSpan={reusableView.content?.colSpan ?? 1}> {reusableView.rendered} ); @@ -691,15 +690,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { let onWidthsApplied = useCallback( (totalWidth: number) => { - let width = `${totalWidth}px`; - let bodyContent = bodyRef.current?.firstElementChild; - if (bodyContent instanceof HTMLElement) { - bodyContent.style.width = width; - } - let headerContent = headerRef.current?.firstElementChild; - if (headerContent instanceof HTMLElement) { - headerContent.style.width = width; - } + setScrollContentWidth(bodyRef, headerRef, totalWidth); }, [bodyRef, headerRef] ); @@ -708,13 +699,18 @@ function TableVirtualizer(props: TableVirtualizerProps) { { tableWidth, getDefaultWidth, - getDefaultMinWidth, - columnWidthRootRef: domRef, - onWidthsApplied + getDefaultMinWidth }, tableState ); + useSyncColumnWidthCSSVars({ + rootRef: domRef, + state: columnResizeState, + tableWidth, + onWidthsApplied + }); + let state = useVirtualizerState, ReactNode>({ layout, collection, @@ -1631,8 +1627,7 @@ function TableCellWrapper({ parent, children, columnIndex, - colSpan = 1, - useColumnCSSVariables = false + colSpan = 1 }: { layoutInfo: LayoutInfo; virtualizer: any; @@ -1640,8 +1635,8 @@ function TableCellWrapper({ children: ReactNode; columnIndex?: number; colSpan?: number; - useColumnCSSVariables?: boolean; }) { + let {direction} = useLocale(); let {isTableDroppable, dropState} = useContext(TableContext)!; let isDropTarget = false; let isRootDroptarget = false; @@ -1656,14 +1651,25 @@ function TableCellWrapper({ isRootDroptarget = dropState.isDropTarget({type: 'root'}); } + let usesColumnCSSVariables = layoutInfo.type === 'column' || layoutInfo.type === 'cell'; + let itemStyle = + usesColumnCSSVariables && columnIndex != null + ? getTableVirtualizerItemStyle( + layoutInfo, + direction, + parent?.layoutInfo, + columnIndex, + colSpan + ) + : undefined; + return ( classNames( @@ -1782,6 +1788,22 @@ function CenteredWrapper({children}) { ); } +function setScrollContentWidth( + bodyRef: RefObject, + headerRef: RefObject, + totalWidth: number +) { + let width = `${totalWidth}px`; + let bodyContent = bodyRef.current?.firstElementChild; + if (bodyContent instanceof HTMLElement) { + bodyContent.style.width = width; + } + let headerContent = headerRef.current?.firstElementChild; + if (headerContent instanceof HTMLElement) { + headerContent.style.width = width; + } +} + const ForwardTableViewBase = React.forwardRef(TableViewBase) as ( props: TableBaseProps & {ref?: DOMRef} ) => ReactElement; diff --git a/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx b/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx index a7e4c2b003f..35384f5d1da 100644 --- a/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx +++ b/packages/@adobe/react-spectrum/test/table/TableSizing.test.tsx @@ -813,6 +813,54 @@ describe('TableViewSizing', function () { expect(tree.queryByRole('slider')).toBeNull(); }); + it('updates cell widths live during drag before pointer up', () => { + simulateDesktop(); + let tree = render( + + + + Foo + + + Bar + + + Baz + + + + {item => {key => {item[key]}}} + + + ); + + fireEvent.pointerMove(tree.container); + let rows = tree.getAllByRole('row'); + // Cells must position via the column CSS variable so imperative drag updates are visible + // live (a raw pixel width would only change after the resize commits). + for (let row of rows) { + let cell = row.childNodes[0] as HTMLElement; + expect(cell.style.width).toBe('var(--col-0-width)'); + } + for (let row of rows) { + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(600); + } + + let resizableHeader = tree.getAllByRole('columnheader')[0]; + fireEvent.pointerEnter(resizableHeader); + let resizer = tree.getByRole('slider'); + fireEvent.pointerEnter(resizer); + + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 600, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 595, pageY: 25}); + // No pointerUp yet: widths must already reflect the drag (live), not only after commit. + for (let row of rows) { + expect(getCellWidth(row.childNodes[0] as HTMLElement)).toBe(595); + } + + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); + }); + it('dragging the resizer works - mobile', () => { let onResizeEnd = jest.fn(); let tree = render( diff --git a/packages/@react-spectrum/s2/test/TableView.test.tsx b/packages/@react-spectrum/s2/test/TableView.test.tsx index c217b1648ea..a777da1f8fd 100644 --- a/packages/@react-spectrum/s2/test/TableView.test.tsx +++ b/packages/@react-spectrum/s2/test/TableView.test.tsx @@ -292,4 +292,41 @@ describe('TableView', () => { await user.click(tableTester.getRows()[0]); expect(onSelectionChange).toHaveBeenCalled(); }); + + it('positions columns via CSS variables so live resizing works (regression)', async () => { + // Regression: the virtualized S2 table must render items through the table-aware + // renderItem so cells read their width from --col-N-width. If they fall back to a + // plain pixel width, column resizing no longer updates the layout live during drag. + let {getByRole} = render( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {column => {item[column.id]}} + + )} + + + ); + await act(() => Promise.resolve()); + + // Every column must have a wrapper that reads its width from the --col-N-width + // variable. Without renderItem wiring these would all be plain pixel widths and no + // var-backed wrapper would exist, so live column resizing would silently break. + let widths = new Set( + Array.from(getByRole('grid').querySelectorAll('[data-column-index]')).map( + el => el.style.width + ) + ); + for (let index of [0, 1, 2, 3]) { + expect(widths.has(`var(--col-${index}-width)`)).toBe(true); + } + }); }); diff --git a/packages/react-aria-components/exports/Virtualizer.ts b/packages/react-aria-components/exports/Virtualizer.ts index 8d71119860b..e6eb3ae181e 100644 --- a/packages/react-aria-components/exports/Virtualizer.ts +++ b/packages/react-aria-components/exports/Virtualizer.ts @@ -15,7 +15,8 @@ import 'client-only'; export {Virtualizer} from '../src/Virtualizer'; -export type {VirtualizerProps} from '../src/Virtualizer'; +export type {VirtualizerItemRenderProps, VirtualizerProps} from '../src/Virtualizer'; +export {renderTableVirtualizerItem, TableVirtualizerItem} from '../src/TableVirtualizerItem'; export { ListLayout, GridLayout, diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index 08d210af006..d7166526624 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -264,6 +264,7 @@ export { DragAndDropContext } from '../src/useDragAndDrop'; export {Virtualizer} from '../src/Virtualizer'; +export {renderTableVirtualizerItem, TableVirtualizerItem} from '../src/TableVirtualizerItem'; export {SSRProvider} from 'react-aria/SSRProvider'; export {RouterProvider} from 'react-aria/private/utils/openLink'; export {I18nProvider, useLocale, isRTL} from 'react-aria/I18nProvider'; @@ -534,7 +535,7 @@ export type { StyleOrFunction, ChildrenOrFunction } from '../src/utils'; -export type {VirtualizerProps} from '../src/Virtualizer'; +export type {VirtualizerItemRenderProps, VirtualizerProps} from '../src/Virtualizer'; export type {DateValue} from 'react-stately/useDateFieldState'; export type {DateRange} from 'react-stately/useDateRangePickerState'; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index c393aca202e..7f58618f54a 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -120,6 +120,7 @@ import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatte import {useMultipleSelectionState} from 'react-stately/useMultipleSelectionState'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useResizeObserver} from 'react-aria/private/utils/useResizeObserver'; +import {useSyncColumnWidthCSSVars} from 'react-aria/private/table/columnWidthDOM'; import { useTable, useTableCell, @@ -886,8 +887,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl // oxlint-disable-next-line react/react-compiler layoutState = tableContainerContext.useTableColumnResizeState( { - tableWidth: tableContainerContext.tableWidth, - columnWidthRootRef: tableContainerContext.columnWidthRootRef + tableWidth: tableContainerContext.tableWidth }, filteredState ); @@ -902,6 +902,13 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl } } + let nullRootRef = useRef(null); + useSyncColumnWidthCSSVars({ + rootRef: tableContainerContext?.columnWidthRootRef ?? nullRootRef, + state: layoutState, + tableWidth: tableContainerContext?.tableWidth ?? 0 + }); + let DOMProps = filterDOMProps(props, {global: true}); return ( diff --git a/packages/react-aria-components/src/TableLayout.ts b/packages/react-aria-components/src/TableLayout.ts index 767f2ed66a1..9ca948eb725 100644 --- a/packages/react-aria-components/src/TableLayout.ts +++ b/packages/react-aria-components/src/TableLayout.ts @@ -12,6 +12,7 @@ import {TableLayout as BaseTableLayout, TableLayoutProps} from 'react-stately/useVirtualizerState'; import {LayoutOptionsDelegate} from './Virtualizer'; +import {renderTableVirtualizerItem} from './TableVirtualizerItem'; import {TableColumnResizeStateContext} from './Table'; import {useContext, useMemo} from 'react'; @@ -19,6 +20,11 @@ export class TableLayout extends BaseTableLayout implements LayoutOptionsDelegate { + // Tables position columns/cells via CSS variables, so the Virtualizer must wrap items + // with the table-aware renderer. Carrying it on the layout means callers can't forget it + // (a plain VirtualizerItem would use pixel widths and break live column resizing). + renderItem = renderTableVirtualizerItem; + // Invalidate the layout whenever the column widths change. useLayoutOptions(): TableLayoutProps { // This is not a React class component, just a regular class. diff --git a/packages/react-aria-components/src/TableVirtualizerItem.tsx b/packages/react-aria-components/src/TableVirtualizerItem.tsx new file mode 100644 index 00000000000..b3371033dd9 --- /dev/null +++ b/packages/react-aria-components/src/TableVirtualizerItem.tsx @@ -0,0 +1,53 @@ +/* + * 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 {getTableVirtualizerItemStyle} from 'react-aria/private/table/tableVirtualizerStyle'; +import {GridNode} from 'react-stately/private/grid/GridCollection'; +import React, {JSX} from 'react'; +import {useLocale} from 'react-aria/I18nProvider'; +import {VirtualizerItem} from 'react-aria/private/virtualizer/VirtualizerItem'; +import {VirtualizerItemRenderProps} from './Virtualizer'; + +/** + * A virtualizer item renderer for tables that reads column widths from CSS custom + * properties on an ancestor. Use as the `renderItem` prop on `Virtualizer` when + * rendering a resizable virtualized table. + */ +export function TableVirtualizerItem(props: VirtualizerItemRenderProps): JSX.Element { + let {viewKey, layoutInfo, virtualizer, parent, children, content} = props; + let {direction} = useLocale(); + + let gridNode = content as GridNode | null | undefined; + let columnIndex = gridNode != null ? (gridNode.colIndex ?? gridNode.index) : undefined; + let colSpan = gridNode?.colSpan ?? 1; + let usesColumnCSSVariables = layoutInfo.type === 'column' || layoutInfo.type === 'cell'; + let style = + usesColumnCSSVariables && columnIndex != null + ? getTableVirtualizerItemStyle(layoutInfo, direction, parent, columnIndex, colSpan) + : undefined; + + return ( + + {children} + + ); +} + +export function renderTableVirtualizerItem(props: VirtualizerItemRenderProps): JSX.Element { + return ; +} diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 701e6ee5182..5748c82542a 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -17,10 +17,10 @@ import { CollectionRootProps, renderAfterDropIndicators } from './Collection'; -import {DropTargetDelegate, ItemDropTarget, Node} from '@react-types/shared'; -import {GridNode} from 'react-stately/private/grid/GridCollection'; +import {DropTargetDelegate, ItemDropTarget, Key, Node} from '@react-types/shared'; import { Layout, + LayoutInfo, ReusableView, useVirtualizerState, VirtualizerState @@ -36,12 +36,28 @@ export interface LayoutOptionsDelegate { } interface ILayout - extends Layout, O>, Partial, LayoutOptionsDelegate {} + extends Layout, O>, Partial, LayoutOptionsDelegate { + /** + * A default item renderer supplied by the layout itself. Used when the `renderItem` + * prop is not provided, so layouts with special item requirements (e.g. tables that + * position cells via CSS variables) work correctly without callers wiring it up. + */ + renderItem?: (props: VirtualizerItemRenderProps) => ReactNode; +} interface LayoutClass { new (): ILayout; } +export interface VirtualizerItemRenderProps { + viewKey: Key; + layoutInfo: LayoutInfo; + virtualizer: ReusableView, ReactNode>['virtualizer']; + parent: LayoutInfo | null; + children: ReactNode; + content: Node | null; +} + export interface VirtualizerProps { /** The child collection to virtualize (e.g. ListBox, GridList, or Table). */ children: ReactNode; @@ -49,23 +65,41 @@ export interface VirtualizerProps { layout: LayoutClass | ILayout; /** Options for the layout. */ layoutOptions?: O; + /** + * A custom renderer for virtualizer items. When not provided, items are wrapped in + * the default `VirtualizerItem` component. + */ + renderItem?: (props: VirtualizerItemRenderProps) => ReactNode; } interface LayoutContextValue { layout: ILayout; layoutOptions?: any; + renderItem?: (props: VirtualizerItemRenderProps) => ReactNode; } const VirtualizerContext = createContext | null>(null); const LayoutContext = createContext(null); +function defaultRenderItem(props: VirtualizerItemRenderProps): ReactNode { + return ( + + {props.children} + + ); +} + /** * A Virtualizer renders a scrollable collection of data using customizable layouts. * It supports very large collections by only rendering visible items to the DOM, reusing * them as the user scrolls. */ export function Virtualizer(props: VirtualizerProps): JSX.Element { - let {children, layout: layoutProp, layoutOptions} = props; + let {children, layout: layoutProp, layoutOptions, renderItem} = props; let layout = useMemo( () => (typeof layoutProp === 'function' ? new layoutProp() : layoutProp), [layoutProp] @@ -85,7 +119,10 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { return ( - {children} + + {children} + ); } @@ -96,7 +133,7 @@ function CollectionRoot({ scrollRef, renderDropIndicator }: CollectionRootProps) { - let {layout, layoutOptions} = useContext(LayoutContext)!; + let {layout, layoutOptions, renderItem} = useContext(LayoutContext)!; // oxlint-disable-next-line react/react-compiler let layoutOptions2 = layout.useLayoutOptions?.(); let state = useVirtualizerState({ @@ -138,7 +175,7 @@ function CollectionRoot({ return (
- {renderChildren(null, state.visibleViews, renderDropIndicator)} + {renderChildren(null, state.visibleViews, renderDropIndicator, renderItem)}
); @@ -146,41 +183,39 @@ function CollectionRoot({ function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) { let virtualizer = useContext(VirtualizerContext); + let {renderItem} = useContext(LayoutContext)!; let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; - return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); + return renderChildren( + parentView, + Array.from(parentView.children), + renderDropIndicator, + renderItem + ); } function renderChildren( parent: View | null, children: View[], - renderDropIndicator?: (target: ItemDropTarget) => ReactNode + renderDropIndicator?: (target: ItemDropTarget) => ReactNode, + renderItem?: (props: VirtualizerItemRenderProps) => ReactNode ) { - return children.map(view => renderWrapper(parent, view, renderDropIndicator)); + return children.map(view => renderWrapper(parent, view, renderDropIndicator, renderItem)); } function renderWrapper( parent: View | null, reusableView: View, - renderDropIndicator?: (target: ItemDropTarget) => ReactNode + renderDropIndicator?: (target: ItemDropTarget) => ReactNode, + renderItem: (props: VirtualizerItemRenderProps) => ReactNode = defaultRenderItem ): ReactNode { - let layoutInfo = reusableView.layoutInfo!; - let useColumnCSSVariables = layoutInfo.type === 'column' || layoutInfo.type === 'cell'; - let gridNode = reusableView.content as GridNode | null | undefined; - let columnIndex = gridNode != null ? (gridNode.colIndex ?? gridNode.index) : undefined; - let colSpan = gridNode?.colSpan ?? 1; - - let rendered = ( - - {reusableView.rendered} - - ); + let rendered = renderItem({ + viewKey: reusableView.key, + layoutInfo: reusableView.layoutInfo!, + virtualizer: reusableView.virtualizer, + parent: parent?.layoutInfo ?? null, + children: reusableView.rendered, + content: reusableView.content + }); let {collection, layout} = reusableView.virtualizer; let node = reusableView.content; @@ -191,11 +226,12 @@ function renderWrapper( parent, reusableView, {type: 'item', key: reusableView.content!.key, dropPosition: 'before'}, - renderDropIndicator + renderDropIndicator, + renderItem )} {rendered} {renderAfterDropIndicators(collection, node, target => - renderDropIndicatorWrapper(parent, reusableView, target, renderDropIndicator) + renderDropIndicatorWrapper(parent, reusableView, target, renderDropIndicator, renderItem) )} ); @@ -208,19 +244,20 @@ function renderDropIndicatorWrapper( parent: View | null, reusableView: View, target: ItemDropTarget, - renderDropIndicator: (target: ItemDropTarget) => ReactNode + renderDropIndicator: (target: ItemDropTarget) => ReactNode, + renderItem: (props: VirtualizerItemRenderProps) => ReactNode = defaultRenderItem ) { let indicator = renderDropIndicator(target); if (indicator) { let layoutInfo = reusableView.virtualizer.layout.getDropTargetLayoutInfo!(target); - indicator = ( - - {indicator} - - ); + indicator = renderItem({ + viewKey: `${reusableView.key}-drop-${target.dropPosition}`, + layoutInfo, + virtualizer: reusableView.virtualizer, + parent: parent?.layoutInfo ?? null, + children: indicator, + content: null + }); } return indicator; diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 35979dc6098..89679ce35e7 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -42,9 +42,9 @@ import styles from '../example/index.css'; import {TableLayout} from '../src/TableLayout'; import {useAsyncList} from 'react-stately/useAsyncList'; import {useListData} from 'react-stately/useListData'; +import {useTreeData} from 'react-stately'; import {Virtualizer} from '../src/Virtualizer'; import './styles.css'; -import {useTreeData} from 'react-stately'; export default { title: 'React Aria Components/Table', diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 70b65a09a37..b44dbaf4e5b 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -43,6 +43,10 @@ import {composeStories} from '@storybook/react'; import {DataTransfer, DragEvent} from 'react-aria/test/dnd/mocks'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {DropIndicator, useDragAndDrop} from '../src/useDragAndDrop'; +import { + findColumnWidthRoot, + getColumnWidthsFromRow +} from 'react-aria/test/table/columnWidthTestUtils'; import {Label} from '../src/Label'; import {Modal} from '../src/Modal'; import React, {useMemo, useState} from 'react'; @@ -1938,6 +1942,125 @@ describe('Table', () => { let resizers = getAllByTestId('resizer'); expect(resizers).toHaveLength(5); }); + + describe('virtualized', () => { + installPointerEvent(); + let clientWidth, clientHeight; + beforeAll(() => { + clientWidth = jest + .spyOn(window.HTMLElement.prototype, 'clientWidth', 'get') + .mockImplementation(() => 800); + clientHeight = jest + .spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(() => 400); + }); + afterAll(() => { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + let rows = []; + for (let i = 1; i <= 20; i++) { + rows.push({id: i, name: 'Name ' + i, type: 'Type ' + i, height: '' + i}); + } + let columns = [ + {name: 'Name', id: 'name', width: '1fr'}, + {name: 'Type', id: 'type', width: '1fr'}, + {name: 'Height', id: 'height', width: '1fr'} + ]; + + function VirtualizedResizableTable(props) { + return ( + + + + + {column => ( + + {column.name} + + )} + + + {item => ( + {column => {item[column.id]}} + )} + +
+
+
+ ); + } + + // Renders without a "unique key" warning (setupTests throws on console.error), + // so this guards against the Virtualizer renderItem losing its key. + it('renders a resizable virtualized table without key warnings', () => { + let tree = render(); + act(() => { + jest.runAllTimers(); + }); + let headerRow = tree.getByRole('grid').querySelector('[role="row"]'); + let widths = getColumnWidthsFromRow(headerRow); + // 3 equal 1fr columns across 800px table. + expect(widths).toEqual([expect.any(Number), expect.any(Number), expect.any(Number)]); + expect(widths.every(w => w > 0)).toBe(true); + }); + + it('positions cells via column CSS variables and data-column-index', () => { + let tree = render(); + act(() => { + jest.runAllTimers(); + }); + // Cells rendered through the table-aware renderItem carry data-column-index + // and position via the --col-N-width CSS variable (not a raw pixel width, which + // would not update live during an imperative drag). + let indexedCells = tree.getByRole('grid').querySelectorAll('[data-column-index]'); + expect(indexedCells.length).toBeGreaterThan(0); + let columnHeaderWrapper = tree + .getAllByRole('columnheader')[0] + .closest('[data-column-index]'); + expect(columnHeaderWrapper.style.width).toBe('var(--col-0-width)'); + let headerRow = tree.getByRole('grid').querySelector('[role="row"]'); + expect(getColumnWidthsFromRow(headerRow).every(w => Number.isFinite(w) && w > 0)).toBe( + true + ); + }); + + it('updates column width CSS variables live during drag (before pointer up)', () => { + let onResize = jest.fn(); + let tree = render(); + act(() => { + jest.runAllTimers(); + }); + + let headerRow = tree.getByRole('grid').querySelector('[role="row"]'); + let root = findColumnWidthRoot(headerRow); + expect(root).not.toBeNull(); + let before = root.style.getPropertyValue('--col-0-width'); + + // Start the drag and move without releasing: widths should update imperatively mid-drag. + act(() => { + setInteractionModality('pointer'); + }); + let column = getColumn(tree, 'Name'); + let resizer = within(column).getByRole('slider'); + fireEvent.pointerEnter(resizer); + fireEvent.pointerDown(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 0, pageY: 30}); + fireEvent.pointerMove(resizer, {pointerType: 'mouse', pointerId: 1, pageX: 100, pageY: 25}); + act(() => { + jest.runAllTimers(); + }); + + let during = root.style.getPropertyValue('--col-0-width'); + expect(during).not.toBe(before); + expect(parseFloat(during)).toBeGreaterThan(parseFloat(before)); + + fireEvent.pointerUp(resizer, {pointerType: 'mouse', pointerId: 1}); + }); + }); }); it('should support overriding table style', () => { diff --git a/packages/react-aria/exports/private/table/columnWidthDOM.ts b/packages/react-aria/exports/private/table/columnWidthDOM.ts new file mode 100644 index 00000000000..51f5ca2e58b --- /dev/null +++ b/packages/react-aria/exports/private/table/columnWidthDOM.ts @@ -0,0 +1,2 @@ +export {useSyncColumnWidthCSSVars, applyColumnWidthsToDOM} from '../../../src/table/columnWidthDOM'; +export type {UseSyncColumnWidthCSSVarsOptions} from '../../../src/table/columnWidthDOM'; diff --git a/packages/react-aria/exports/private/table/tableVirtualizerStyle.ts b/packages/react-aria/exports/private/table/tableVirtualizerStyle.ts new file mode 100644 index 00000000000..3fe838e808a --- /dev/null +++ b/packages/react-aria/exports/private/table/tableVirtualizerStyle.ts @@ -0,0 +1 @@ +export {getTableVirtualizerItemStyle} from '../../../src/table/tableVirtualizerStyle'; diff --git a/packages/react-aria/src/table/columnWidthDOM.ts b/packages/react-aria/src/table/columnWidthDOM.ts new file mode 100644 index 00000000000..fd398a36be3 --- /dev/null +++ b/packages/react-aria/src/table/columnWidthDOM.ts @@ -0,0 +1,106 @@ +/* + * 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 { + ColumnWidthEntry, + getColumnStartVarName, + getColumnWidthVarName +} from 'react-stately/useTableState'; +import {Key, RefObject} from '@react-types/shared'; +import React, {useCallback} from 'react'; +import {TableColumnResizeState} from 'react-stately/useTableState'; + +const useIsomorphicLayoutEffect: typeof React.useLayoutEffect = + typeof document !== 'undefined' ? React.useLayoutEffect : () => {}; + +/** + * Applies column width CSS custom properties to a table root element. + * Returns the total table content width. + */ +export function applyColumnWidthsToDOM( + root: HTMLElement, + columns: ColumnWidthEntry[], + columnWidths: Map, + resizingColumnKey?: Key | null +): number { + let start = 0; + let totalWidth = 0; + + for (let column of columns) { + let width = columnWidths.get(column.key) ?? 0; + root.style.setProperty(getColumnWidthVarName(column.index), `${width}px`); + root.style.setProperty(getColumnStartVarName(column.index), `${start}px`); + start += width; + totalWidth += width; + } + + root.style.setProperty('--table-total-width', `${totalWidth}px`); + + if (resizingColumnKey != null) { + let indicatorPosition = 0; + for (let column of columns) { + indicatorPosition += columnWidths.get(column.key) ?? 0; + if (column.key === resizingColumnKey) { + root.style.setProperty('--resize-indicator-position', `${indicatorPosition - 2}px`); + break; + } + } + } + + return totalWidth; +} + +export interface UseSyncColumnWidthCSSVarsOptions { + rootRef: RefObject; + state: TableColumnResizeState | null; + tableWidth: number; + onWidthsApplied?: (totalWidth: number) => void; +} + +/** + * Synchronizes committed column width CSS custom properties to the table root. + * Also rebuilds widths when the table viewport resizes during an active column resize. + */ +export function useSyncColumnWidthCSSVars({ + rootRef, + state, + tableWidth, + onWidthsApplied +}: UseSyncColumnWidthCSSVarsOptions): void { + let apply = useCallback(() => { + let root = rootRef.current; + if (root && state) { + let totalWidth = applyColumnWidthsToDOM( + root, + state.columnEntries, + state.getCurrentPixelWidths(), + state.resizingColumn + ); + onWidthsApplied?.(totalWidth); + } + }, [rootRef, state, onWidthsApplied]); + + // Sync CSS variables when committed widths or table width change. + useIsomorphicLayoutEffect(() => { + if (rootRef.current && state && state.resizingColumn == null) { + apply(); + } + }, [state?.columnWidths, tableWidth, apply, state?.resizingColumn, rootRef, state]); + + // Rebuild widths when the table is resized during an active column resize. + useIsomorphicLayoutEffect(() => { + if (state?.resizingColumn != null) { + state.rebuildWidthsForViewportResize(); + apply(); + } + }, [tableWidth, state?.resizingColumn, apply, state]); +} diff --git a/packages/react-aria/src/table/tableVirtualizerStyle.ts b/packages/react-aria/src/table/tableVirtualizerStyle.ts new file mode 100644 index 00000000000..970a4a9adce --- /dev/null +++ b/packages/react-aria/src/table/tableVirtualizerStyle.ts @@ -0,0 +1,47 @@ +/* + * 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 {CSSProperties} from 'react'; +import {Direction} from '@react-types/shared'; +import {getColumnHorizontalStyle} from 'react-stately/useTableState'; +import {LayoutInfo} from 'react-stately/useVirtualizerState'; +import {layoutInfoToStyle} from '../virtualizer/VirtualizerItem'; + +/** + * Returns styles for a table column or cell virtualizer wrapper that reads + * horizontal positioning from CSS custom properties on an ancestor. + */ +export function getTableVirtualizerItemStyle( + layoutInfo: LayoutInfo, + direction: Direction, + parent: LayoutInfo | null | undefined, + columnIndex?: number, + colSpan: number = 1 +): CSSProperties { + let usesColumnCSSVars = layoutInfo.type === 'column' || layoutInfo.type === 'cell'; + + if (!usesColumnCSSVars || columnIndex == null) { + return layoutInfoToStyle(layoutInfo, direction, parent); + } + + let baseStyle = layoutInfoToStyle(layoutInfo, direction, parent); + let xProperty = direction === 'rtl' ? 'right' : 'left'; + let columnStyle = getColumnHorizontalStyle(columnIndex, colSpan); + + return { + ...baseStyle, + ...columnStyle, + [xProperty]: undefined, + width: columnStyle.width, + contain: 'layout style' + }; +} diff --git a/packages/react-aria/src/table/useTableColumnResize.ts b/packages/react-aria/src/table/useTableColumnResize.ts index b51c0c7deb7..4a600923750 100644 --- a/packages/react-aria/src/table/useTableColumnResize.ts +++ b/packages/react-aria/src/table/useTableColumnResize.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {applyColumnWidthsToDOM} from './columnWidthDOM'; import {ChangeEvent, useCallback, useEffect, useRef, useState} from 'react'; import {ColumnSize} from 'react-stately/useTableState'; import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; @@ -114,7 +115,12 @@ export function useTableColumnResize( (resizingColumnKey?: Key) => { let root = columnWidthRootRef?.current; if (root) { - state.applyToDOM(root, resizingColumnKey ?? state.resizingColumn); + applyColumnWidthsToDOM( + root, + state.columnEntries, + state.getCurrentPixelWidths(), + resizingColumnKey ?? state.resizingColumn + ); } }, [columnWidthRootRef, state] diff --git a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx index 57b936f0fed..e4d456d30d0 100644 --- a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx +++ b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx @@ -11,7 +11,6 @@ */ import {Direction} from '@react-types/shared'; -import {getColumnHorizontalStyle} from 'react-stately/useTableState'; import {LayoutInfo} from 'react-stately/useVirtualizerState'; import React, {CSSProperties, JSX, ReactNode, useRef} from 'react'; import {useLocale} from '../i18n/I18nProvider'; @@ -23,24 +22,11 @@ interface VirtualizerItemProps extends Omit { style?: CSSProperties; className?: string; children: ReactNode; - /** When true, horizontal positioning uses CSS custom properties instead of inline styles. */ - useColumnCSSVariables?: boolean; - columnIndex?: number; - colSpan?: number; + nativeProps?: Record; } export function VirtualizerItem(props: VirtualizerItemProps): JSX.Element { - let { - style, - className, - layoutInfo, - virtualizer, - parent, - children, - useColumnCSSVariables, - columnIndex, - colSpan - } = props; + let {style, className, layoutInfo, virtualizer, parent, children, nativeProps} = props; let {direction} = useLocale(); let ref = useRef(null); useVirtualizerItem({ @@ -49,22 +35,13 @@ export function VirtualizerItem(props: VirtualizerItemProps): JSX.Element { ref }); - let columnStyle = - useColumnCSSVariables && columnIndex != null - ? getColumnHorizontalStyle(columnIndex, colSpan ?? 1) - : undefined; - return (
+ {...nativeProps} + style={{...layoutInfoToStyle(layoutInfo, direction, parent), ...style}}> {children}
); @@ -74,11 +51,8 @@ let cache = new WeakMap(); export function layoutInfoToStyle( layoutInfo: LayoutInfo, dir: Direction, - parent?: LayoutInfo | null, - useColumnCSSVariables?: boolean + parent?: LayoutInfo | null ): CSSProperties { - let usesColumnCSSVars = - useColumnCSSVariables && (layoutInfo.type === 'column' || layoutInfo.type === 'cell'); let xProperty = dir === 'rtl' ? 'right' : 'left'; let cached = cache.get(layoutInfo); if (cached && cached[xProperty] != null) { @@ -88,8 +62,8 @@ export function layoutInfoToStyle( // Invalidate if the parent position changed. let top = layoutInfo.rect.y - parent.rect.y; - let x = usesColumnCSSVars ? undefined : layoutInfo.rect.x - parent.rect.x; - if (cached.top === top && (usesColumnCSSVars || cached[xProperty] === x)) { + let x = layoutInfo.rect.x - parent.rect.x; + if (cached.top === top && cached[xProperty] === x) { return cached; } } @@ -102,16 +76,13 @@ export function layoutInfoToStyle( top: layoutInfo.rect.y - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.y : 0), + [xProperty]: + layoutInfo.rect.x - + (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.x : 0), + width: layoutInfo.rect.width, height: layoutInfo.rect.height }; - if (!usesColumnCSSVars) { - rectStyles[xProperty] = - layoutInfo.rect.x - - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.x : 0); - rectStyles.width = layoutInfo.rect.width; - } - // Get rid of any non finite values since they aren't valid css values Object.entries(rectStyles).forEach(([key, value]) => { if (!Number.isFinite(value)) { @@ -127,7 +98,7 @@ export function layoutInfoToStyle( opacity: layoutInfo.opacity, zIndex: layoutInfo.zIndex, transform: layoutInfo.transform ?? undefined, - contain: usesColumnCSSVars ? 'layout style' : 'size layout style', + contain: 'size layout style', ...rectStyles }; diff --git a/packages/react-stately/exports/index.ts b/packages/react-stately/exports/index.ts index f91ab67ede8..27069c553df 100644 --- a/packages/react-stately/exports/index.ts +++ b/packages/react-stately/exports/index.ts @@ -215,7 +215,6 @@ export {Row} from '../src/table/Row'; export {Cell} from '../src/table/Cell'; export {useTableColumnResizeState} from '../src/table/useTableColumnResizeState'; export { - applyColumnWidthsToDOM, getColumnHorizontalStyle, getColumnStartVarName, getColumnWidthVarName, diff --git a/packages/react-stately/exports/useTableState.ts b/packages/react-stately/exports/useTableState.ts index c35cd2cb8bc..5c7ab08858f 100644 --- a/packages/react-stately/exports/useTableState.ts +++ b/packages/react-stately/exports/useTableState.ts @@ -18,7 +18,6 @@ export type {TableProps, TableState, TableStateProps} from '../src/table/useTabl export {useTableColumnResizeState} from '../src/table/useTableColumnResizeState'; export { - applyColumnWidthsToDOM, getColumnHorizontalStyle, getColumnStartVarName, getColumnWidthVarName, diff --git a/packages/react-stately/src/table/columnWidthCSS.ts b/packages/react-stately/src/table/columnWidthCSS.ts index b4615ea68fa..934547e17e6 100644 --- a/packages/react-stately/src/table/columnWidthCSS.ts +++ b/packages/react-stately/src/table/columnWidthCSS.ts @@ -26,43 +26,6 @@ export interface ColumnWidthEntry { index: number; } -/** - * Applies column width CSS custom properties to a table root element. - * Returns the total table content width. - */ -export function applyColumnWidthsToDOM( - root: HTMLElement, - columns: ColumnWidthEntry[], - columnWidths: Map, - resizingColumnKey?: Key | null -): number { - let start = 0; - let totalWidth = 0; - - for (let column of columns) { - let width = columnWidths.get(column.key) ?? 0; - root.style.setProperty(getColumnWidthVarName(column.index), `${width}px`); - root.style.setProperty(getColumnStartVarName(column.index), `${start}px`); - start += width; - totalWidth += width; - } - - root.style.setProperty('--table-total-width', `${totalWidth}px`); - - if (resizingColumnKey != null) { - let indicatorPosition = 0; - for (let column of columns) { - indicatorPosition += columnWidths.get(column.key) ?? 0; - if (column.key === resizingColumnKey) { - root.style.setProperty('--resize-indicator-position', `${indicatorPosition - 2}px`); - break; - } - } - } - - return totalWidth; -} - /** * Returns mount-time styles for a column or cell wrapper that reads horizontal * positioning from CSS custom properties on an ancestor. diff --git a/packages/react-stately/src/table/useTableColumnResizeState.ts b/packages/react-stately/src/table/useTableColumnResizeState.ts index 2e294439d69..485ab46fdb6 100644 --- a/packages/react-stately/src/table/useTableColumnResizeState.ts +++ b/packages/react-stately/src/table/useTableColumnResizeState.ts @@ -10,16 +10,13 @@ * governing permissions and limitations under the License. */ -import {applyColumnWidthsToDOM, ColumnWidthEntry, columnWidthsEqual} from './columnWidthCSS'; import {ColumnSize} from './Column'; +import {ColumnWidthEntry, columnWidthsEqual} from './columnWidthCSS'; import {GridNode} from '../grid/GridCollection'; -import {Key, RefObject} from '@react-types/shared'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {Key} from '@react-types/shared'; import {TableColumnLayout} from './TableColumnLayout'; import {TableState} from './useTableState'; - -const useLayoutEffect: typeof React.useLayoutEffect = - typeof document !== 'undefined' ? React.useLayoutEffect : () => {}; +import {useCallback, useMemo, useRef, useState} from 'react'; function buildPixelWidths( layout: TableColumnLayout, @@ -44,16 +41,6 @@ export interface TableColumnResizeStateProps { getDefaultWidth?: (node: GridNode) => ColumnSize | null | undefined; /** A function that is called to find the default minWidth for a given column. */ getDefaultMinWidth?: (node: GridNode) => ColumnSize | null | undefined; - /** - * Ref to the table root element where column width CSS custom properties - * should be applied. - */ - columnWidthRootRef?: RefObject; - /** - * Called after column widths are applied to the DOM. Can be used to update - * scroll container sizes without a React re-render. - */ - onWidthsApplied?: (totalWidth: number) => void; } export interface TableColumnResizeState { @@ -78,8 +65,12 @@ export interface TableColumnResizeState { tableState: TableState; /** A map of the current committed column widths. */ columnWidths: Map; - /** Applies column width CSS custom properties to the table root element. */ - applyToDOM: (root: HTMLElement, resizingColumnKey?: Key | null) => void; + /** Column entries with keys and indices for CSS variable application. */ + columnEntries: ColumnWidthEntry[]; + /** Returns the current pixel column widths, including during an active resize. */ + getCurrentPixelWidths: () => Map; + /** Rebuilds pixel widths when the table viewport resizes during an active column resize. */ + rebuildWidthsForViewportResize: () => void; } /** @@ -95,13 +86,7 @@ export function useTableColumnResizeState( props: TableColumnResizeStateProps, state: TableState ): TableColumnResizeState { - let { - getDefaultWidth, - getDefaultMinWidth, - tableWidth = 0, - columnWidthRootRef, - onWidthsApplied - } = props; + let {getDefaultWidth, getDefaultMinWidth, tableWidth = 0} = props; let [resizingColumn, setResizingColumn] = useState(null); let isResizingRef = useRef(false); @@ -163,6 +148,7 @@ export function useTableColumnResizeState( ] ); + // oxlint-disable react/react-compiler let columnWidths = useMemo(() => { let sizes = colWidths; @@ -183,34 +169,16 @@ export function useTableColumnResizeState( return columnLayout.buildColumnWidths(tableWidth, state.collection, sizes); }, [tableWidth, state.collection, colWidths, columnLayout]); + // oxlint-enable react/react-compiler - let applyToDOM = useCallback( - (root: HTMLElement, activeResizingColumn?: Key | null) => { - let totalWidth = applyColumnWidthsToDOM( - root, - columnEntries, - columnLayout.columnWidths, - activeResizingColumn ?? resizingColumn - ); - onWidthsApplied?.(totalWidth); - }, - [columnEntries, columnLayout, resizingColumn, onWidthsApplied] - ); - - // Sync CSS variables when committed widths or table width change. - useLayoutEffect(() => { - if (columnWidthRootRef?.current && !isResizingRef.current) { - applyToDOM(columnWidthRootRef.current); - } - }, [columnWidths, columnWidthRootRef, applyToDOM, tableWidth]); + let getCurrentPixelWidths = useCallback(() => columnLayout.columnWidths, [columnLayout]); - // Rebuild widths when the table is resized during an active column resize. - useLayoutEffect(() => { - if (isResizingRef.current && columnWidthRootRef?.current && pendingSizesRef.current) { + let rebuildWidthsForViewportResize = useCallback(() => { + // oxlint-disable-next-line react/react-compiler + if (isResizingRef.current && pendingSizesRef.current) { columnLayout.buildColumnWidths(tableWidth, state.collection, pendingSizesRef.current); - applyToDOM(columnWidthRootRef.current); } - }, [tableWidth, columnWidthRootRef, columnLayout, state.collection, applyToDOM]); + }, [columnLayout, tableWidth, state.collection]); let startResize = useCallback( (key: Key) => { @@ -237,6 +205,7 @@ export function useTableColumnResizeState( Array.from(uncontrolledColumns).map(([colKey]) => [colKey, newSizes.get(colKey)!]) ); map.set(key, width); + // oxlint-disable-next-line react/react-compiler pendingSizesRef.current = newSizes; if (isResizingRef.current) { @@ -251,13 +220,14 @@ export function useTableColumnResizeState( [uncontrolledColumns, columnLayout, state.collection, uncontrolledWidths, tableWidth] ); + // oxlint-disable-next-line react/react-compiler let endResize = useCallback(() => { if (isResizingRef.current) { setUncontrolledWidths(pendingUncontrolledWidthsRef.current); isResizingRef.current = false; } setResizingColumn(null); - }, []); + }, [setUncontrolledWidths]); return useMemo( () => ({ @@ -270,17 +240,21 @@ export function useTableColumnResizeState( getColumnMaxWidth: (key: Key) => columnLayout.getColumnMaxWidth(key), tableState: state, columnWidths, - applyToDOM + columnEntries, + getCurrentPixelWidths, + rebuildWidthsForViewportResize }), [ columnLayout, columnWidths, + columnEntries, resizingColumn, updateResizedColumns, startResize, endResize, - state, - applyToDOM + getCurrentPixelWidths, + rebuildWidthsForViewportResize, + state ] ); }