From 3b82fb5e4b5d9e44a8ad898b0b4eba9c039852b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 10 Jul 2025 16:29:15 +0200 Subject: [PATCH 1/6] feat: add node component to collection renderer --- .../@react-spectrum/s2/src/Breadcrumbs.tsx | 9 ++- .../react-aria-components/src/Collection.tsx | 55 ++++++++++---- .../react-aria-components/src/Virtualizer.tsx | 74 ++++++++++--------- 3 files changed, 83 insertions(+), 55 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index 71527967aa6..2cce6f9b307 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -29,7 +29,7 @@ import {baseColor, focusRing, size, style} from '../style' with { type: 'macro' import ChevronIcon from '../ui-icons/Chevron'; import {Collection, DOMRef, DOMRefValue, LinkDOMProps, Node} from '@react-types/shared'; import {controlFont, controlSize, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {createContext, forwardRef, Fragment, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import FolderIcon from '../s2wf-icons/S2_Icon_FolderBreadcrumb_20_N.svg'; import {forwardRefType} from './types'; import {inertValue, useLayoutEffect} from '@react-aria/utils'; @@ -380,6 +380,7 @@ let CollapsingCollectionRenderer: CollectionRenderer = { }; let useCollectionRender = (collection: Collection>) => { + let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); let {containerRef, onAction} = useContext(CollapseContext) ?? {}; let [visibleItems, setVisibleItems] = useState(collection.size); let {size = 'M'} = useContext(InternalBreadcrumbsContext); @@ -468,13 +469,13 @@ let useCollectionRender = (collection: Collection>) => { {visibleItems < collection.size && collection.size > 2 ? ( <> - {children[0].render?.(children[0])} + - {children.slice(sliceIndex).map(node => {node.render?.(node)})} + {children.slice(sliceIndex).map(node => )} ) : ( <> - {children.map(node => {node.render?.(node)})} + {children.map(node => )} )} diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 8139c31249f..5e0bf0ad5b0 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -108,7 +108,20 @@ export const Section = /*#__PURE__*/ createBranchComponent('section', > extends HTMLAttributes { + /** The collection of items to render. */ + collection: ICollection>, + /** The node of the item to render. */ + node: T, + /** The parent node of the item to render. */ + parent: T | null, + /** The content that should be rendered before the item. */ + before?: ReactNode, + /** The content that should be rendered after the item. */ + after?: ReactNode +} + +export interface CollectionBranchProps extends HTMLAttributes { /** The collection of items to render. */ collection: ICollection>, /** The parent node of the items to render. */ @@ -128,7 +141,7 @@ export interface CollectionRootProps extends HTMLAttributes { renderDropIndicator?: (target: ItemDropTarget, keys?: Set, draggedKey?: Key) => ReactNode } -export interface CollectionRenderer { +export interface CollectionRenderer> { /** Whether this is a virtualized collection. */ isVirtualized?: boolean, /** A delegate object that provides layout information for items in the collection. */ @@ -138,15 +151,25 @@ export interface CollectionRenderer { /** A component that renders the root collection items. */ CollectionRoot: React.ComponentType, /** A component that renders the child collection items. */ - CollectionBranch: React.ComponentType + CollectionBranch: React.ComponentType, + /** A component that renders the collection item. */ + CollectionNode?: React.ComponentType> } -export const DefaultCollectionRenderer: CollectionRenderer = { +interface DefaultRenderer extends CollectionRenderer> { + /** A component that renders the collection item. */ + CollectionNode: React.ComponentType>> +} + +export const DefaultCollectionRenderer: DefaultRenderer = { CollectionRoot({collection, renderDropIndicator}) { return useCollectionRender(collection, null, renderDropIndicator); }, CollectionBranch({collection, parent, renderDropIndicator}) { return useCollectionRender(collection, parent, renderDropIndicator); + }, + CollectionNode({node, before, after}) { + return <>{before}{node.render?.(node)}{after}; } }; @@ -155,22 +178,22 @@ function useCollectionRender( parent: Node | null, renderDropIndicator?: (target: ItemDropTarget, keys?: Set, draggedKey?: Key) => ReactNode ) { + let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); + return useCachedChildren({ items: parent ? collection.getChildren!(parent.key) : collection, - dependencies: [renderDropIndicator], + dependencies: [CollectionNode, renderDropIndicator], children(node) { - let rendered = node.render!(node); - if (!renderDropIndicator || node.type !== 'item') { - return rendered; + let pseudoProps = {}; + + if (renderDropIndicator && node.type !== 'item') { + pseudoProps = { + before: renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'}), + after: renderAfterDropIndicators(collection, node, renderDropIndicator) + }; } - return ( - <> - {renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'})} - {rendered} - {renderAfterDropIndicators(collection, node, renderDropIndicator)} - - ); + return ; } }); } @@ -211,7 +234,7 @@ export function renderAfterDropIndicators(collection: ICollection> return afterIndicators; } -export const CollectionRendererContext = createContext(DefaultCollectionRenderer); +export const CollectionRendererContext = createContext>(DefaultCollectionRenderer); type PersistedKeysReturnValue = Set | null; export function usePersistedKeys(focusedKey: Key | null): PersistedKeysReturnValue { diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 0f6d13c7667..3bc02fd2e8c 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import {CollectionBranchProps, CollectionRenderer, CollectionRendererContext, CollectionRootProps, renderAfterDropIndicators} from './Collection'; +import {CollectionBranchProps, CollectionNodeProps, CollectionRenderer, CollectionRendererContext, CollectionRootProps, renderAfterDropIndicators} from './Collection'; import {DropTargetDelegate, ItemDropTarget, Key, Node} from '@react-types/shared'; import {Layout, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; +import {mergeProps} from '@react-aria/utils'; import React, {createContext, JSX, ReactNode, useContext, useMemo} from 'react'; import {useScrollView, VirtualizerItem} from '@react-aria/virtualizer'; @@ -53,12 +54,13 @@ const LayoutContext = createContext(null); export function Virtualizer(props: VirtualizerProps): JSX.Element { let {children, layout: layoutProp, layoutOptions} = props; let layout = useMemo(() => typeof layoutProp === 'function' ? new layoutProp() : layoutProp, [layoutProp]); - let renderer: CollectionRenderer = useMemo(() => ({ + let renderer: CollectionRenderer, ReactNode>> = useMemo(() => ({ isVirtualized: true, layoutDelegate: layout, dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined, CollectionRoot, - CollectionBranch + CollectionBranch, + CollectionNode }), [layout]); return ( @@ -70,7 +72,7 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { ); } -function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator}: CollectionRootProps) { +function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator, ...props}: CollectionRootProps) { let {layout, layoutOptions} = useContext(LayoutContext)!; let layoutOptions2 = layout.useLayoutOptions?.(); let state = useVirtualizerState({ @@ -103,9 +105,9 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat }, scrollRef!); return ( -
+
- {renderChildren(null, state.visibleViews, renderDropIndicator)} + {useRenderChildren(null, state.visibleViews, renderDropIndicator)}
); @@ -114,41 +116,43 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) { let virtualizer = useContext(VirtualizerContext); let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; - return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); -} -function renderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget, keys?: Set, draggedKey?: Key) => ReactNode) { - return children.map(view => renderWrapper(parent, view, renderDropIndicator)); + return useRenderChildren(parentView, Array.from(parentView.children), renderDropIndicator); } -function renderWrapper( - parent: View | null, - reusableView: View, - renderDropIndicator?: (target: ItemDropTarget, keys?: Set, draggedKey?: Key) => ReactNode -): ReactNode { - let rendered = ( - - {reusableView.rendered} - +function CollectionNode({node, parent, before, after, ...props}: CollectionNodeProps, ReactNode>>) { + return ( + <> + {before} + + {node.rendered} + + {after} + ); +} - let {collection, layout} = reusableView.virtualizer; - let node = reusableView.content; - if (node?.type === 'item' && renderDropIndicator && layout.getDropTargetLayoutInfo) { - rendered = ( - - {renderDropIndicatorWrapper(parent, reusableView, {type: 'item', key: reusableView.content!.key, dropPosition: 'before'}, (target, keys, draggedKey) => renderDropIndicator(target, keys, draggedKey))} - {rendered} - {renderAfterDropIndicators(collection, node, (target, keys, draggedKey) => renderDropIndicatorWrapper(parent, reusableView, target, (innerTarget, innerKeys, innerDraggedKey) => renderDropIndicator(innerTarget, innerKeys, innerDraggedKey), keys, draggedKey))} - - ); - } +function useRenderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget, keys?: Set, draggedKey?: Key) => ReactNode) { + let {CollectionNode: Item = CollectionNode} = useContext(CollectionRendererContext); - return rendered; + return children.map(node => { + let {collection, layout} = node.virtualizer; + let pseudoProps = {}; + + if (layout.getDropTargetLayoutInfo && renderDropIndicator && node.content?.type === 'item') { + pseudoProps = { + before: renderDropIndicatorWrapper(parent, node, {type: 'item', key: node.content!.key, dropPosition: 'before'}, renderDropIndicator), + after: renderAfterDropIndicators(collection, node.content, target => renderDropIndicatorWrapper(parent, node, target, renderDropIndicator)) + }; + } + + return ; + }); } function renderDropIndicatorWrapper( From d6210b600979845ceefd1a8c010e7cc548c8a0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 10 Jul 2025 17:36:14 +0200 Subject: [PATCH 2/6] fix: drop indicators --- packages/react-aria-components/src/Collection.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 5e0bf0ad5b0..d3b1baf46c0 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -169,7 +169,7 @@ export const DefaultCollectionRenderer: DefaultRenderer = { return useCollectionRender(collection, parent, renderDropIndicator); }, CollectionNode({node, before, after}) { - return <>{before}{node.render?.(node)}{after}; + return <>{before}{node.render!(node)}{after}; } }; @@ -182,11 +182,11 @@ function useCollectionRender( return useCachedChildren({ items: parent ? collection.getChildren!(parent.key) : collection, - dependencies: [CollectionNode, renderDropIndicator], + dependencies: [CollectionNode, parent, renderDropIndicator], children(node) { let pseudoProps = {}; - if (renderDropIndicator && node.type !== 'item') { + if (renderDropIndicator && node.type === 'item') { pseudoProps = { before: renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'}), after: renderAfterDropIndicators(collection, node, renderDropIndicator) From 24a20add1c7b8ee14b7ef56e6e51138839ae7272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Fri, 20 Feb 2026 17:46:49 +0100 Subject: [PATCH 3/6] feat: nodeRef & render inheritance --- .gitignore | 1 + .../collections/src/CollectionBuilder.tsx | 13 +++-- .../@react-aria/collections/src/Document.ts | 2 +- .../test/CollectionBuilder.test.js | 30 ++++++---- packages/@react-aria/utils/src/mergeRefs.ts | 2 + .../@react-spectrum/s2/src/Breadcrumbs.tsx | 2 +- .../@react-types/shared/src/collections.d.ts | 4 +- .../react-aria-components/src/Collection.tsx | 22 +++++--- packages/react-aria-components/src/Tree.tsx | 9 +-- .../react-aria-components/src/Virtualizer.tsx | 55 ++++++++----------- 10 files changed, 75 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 178fe850797..a46325f2191 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .cache .idea +.vscode .package-lock.json .parcel-cache build-storybook.log diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index e7585b8ab81..e3d74eb0776 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -17,6 +17,7 @@ import {createPortal} from 'react-dom'; import {FocusableContext} from '@react-aria/interactions'; import {forwardRefType, Key, Node} from '@react-types/shared'; import {Hidden} from './Hidden'; +import {mergeRefs} from '@react-aria/utils'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; import {useSyncExternalStore as useSyncExternalStoreShim} from 'use-sync-external-store/shim/index.js'; @@ -130,7 +131,7 @@ function createCollectionNodeClass(type: string): CollectionNodeClass { return NodeClass; } -function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass | string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { +function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass | string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node, ref?: ForwardedRef) => ReactElement) { // To prevent breaking change, if CollectionNodeClass is a string, create a CollectionNodeClass using the string as the type if (typeof CollectionNodeClass === 'string') { CollectionNodeClass = createCollectionNodeClass(CollectionNodeClass); @@ -169,7 +170,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; export function createLeafComponent

(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node?: any) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null { - let Component = ({node}) => render(node.props, node.props.ref, node); + let Component = (forwardRef as forwardRefType)(({node}: {node: Node}, ref: ForwardedRef) => render(node.props, mergeRefs(node.props.ref, ref), node)); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let focusableProps = useContext(FocusableContext); let isShallow = useContext(ShallowRenderContext); @@ -186,10 +187,10 @@ export function createLeafComponent

(Collect ref, 'children' in props ? props.children : null, null, - node => ( + (node, ref) => ( // Forward FocusableContext to real DOM tree so tooltips work. - + ) ); @@ -200,10 +201,10 @@ export function createLeafComponent

(Collect } export function createBranchComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null, useChildren: (props: P) => ReactNode = useCollectionChildren): (props: P & React.RefAttributes) => ReactElement | null { - let Component = ({node}) => render(node.props, node.props.ref, node); + let Component = (forwardRef as forwardRefType)(({node}: {node: Node}, ref: ForwardedRef) => render(node.props, mergeRefs(node.props.ref, ref), node)); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let children = useChildren(props); - return useSSRCollectionNode(CollectionNodeClass, props, ref, null, children, node => ) ?? <>; + return useSSRCollectionNode(CollectionNodeClass, props, ref, null, children, (node, ref) => ) ?? <>; }); // @ts-ignore Result.displayName = render.name; diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 1bde2a2fdb0..57290afbbaf 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -329,7 +329,7 @@ export class ElementNode extends BaseNode { } } - setProps(obj: {[key: string]: any}, ref: ForwardedRef, CollectionNodeClass: CollectionNodeClass, rendered?: ReactNode, render?: (node: Node) => ReactElement): void { + setProps(obj: {[key: string]: any}, ref: ForwardedRef, CollectionNodeClass: CollectionNodeClass, rendered?: ReactNode, render?: (node: Node, ref?: ForwardedRef) => ReactElement): void { let node; let {value, textValue, id, ...props} = obj; if (this.node == null) { diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 74664dbfc3c..728a5274f06 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -1,33 +1,33 @@ import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent} from '../src'; -import React from 'react'; +import React, {createRef} from 'react'; import {render} from '@testing-library/react'; class ItemNode extends CollectionNode { static type = 'item'; } -const Item = createLeafComponent(ItemNode, () => { - return

; +const Item = createLeafComponent(ItemNode, (props, ref) => { + return
; }); -const ItemsOld = createLeafComponent('item', () => { - return
; +const ItemsOld = createLeafComponent('item', (props, ref) => { + return
; }); -const SectionOld = createBranchComponent('section', () => { - return
; +const SectionOld = createBranchComponent('section', (props, ref) => { + return
; }); -const renderItems = (items, spyCollection) => ( +const renderItems = (items, spyCollection, children = jest.fn()) => ( {items.map((item) => )}}> {collection => { spyCollection.current = collection; - return null; + return children(collection); }} ); -const renderItemsOld = (items, spyCollection) => ( +const renderItemsOld = (items, spyCollection, children = jest.fn()) => ( @@ -40,7 +40,7 @@ const renderItemsOld = (items, spyCollection) => ( }> {collection => { spyCollection.current = collection; - return null; + return children(collection); }} ); @@ -70,4 +70,12 @@ describe('CollectionBuilder', () => { expect(spyCollection.current.keyMap.get('react-aria-2').firstChildKey).toBe('react-aria-1'); expect(spyCollection.current.keyMap.get('react-aria-1').type).toBe('item'); }); + + it('should support ref attachment to a rendered node', () => { + let spyRef = createRef(); + render(renderItems(['a'], {}, collection => Array.from(collection).map( + item => {item.render(item, spyRef)} + ))); + expect(spyRef.current).toBeEmptyDOMElement(); + }); }); diff --git a/packages/@react-aria/utils/src/mergeRefs.ts b/packages/@react-aria/utils/src/mergeRefs.ts index e53c9a71d8b..99a0e3c1634 100644 --- a/packages/@react-aria/utils/src/mergeRefs.ts +++ b/packages/@react-aria/utils/src/mergeRefs.ts @@ -18,6 +18,8 @@ import {MutableRefObject, Ref} from 'react'; export function mergeRefs(...refs: Array | MutableRefObject | null | undefined>): Ref { if (refs.length === 1 && refs[0]) { return refs[0]; + } else if (refs.every(ref => ref == null)) { + return null; } return (value: T | null) => { diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index 6109dce2acd..3654afd6406 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -385,7 +385,7 @@ let CollapsingCollectionRenderer: CollectionRenderer = { }; let useCollectionRender = (collection: Collection>) => { - let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); + let {CollectionNode} = DefaultCollectionRenderer; let {containerRef, onAction} = useContext(CollapseContext) ?? {}; let [visibleItems, setVisibleItems] = useState(collection.size); let {size = 'M'} = useContext(InternalBreadcrumbsContext); diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index a653dfc9768..3d3090ff037 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ +import {ForwardedRef, ReactElement, ReactNode} from 'react'; import {Key} from '@react-types/shared'; import {LinkDOMProps} from './dom'; -import {ReactElement, ReactNode} from 'react'; export interface ItemProps extends LinkDOMProps { /** Rendered contents of the item or child items. */ @@ -223,5 +223,5 @@ export interface Node { /** @private */ shouldInvalidate?: (context: any) => boolean, /** A function that renders this node to a React Element in the DOM. */ - render?: (node: Node) => ReactElement + render?: (node: Node, ref?: ForwardedRef) => ReactElement } diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index a090405e192..79b9046a2ee 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -116,9 +116,13 @@ export interface CollectionNodeProps> extends HTMLAttributes, ref?: ForwardedRef) => ReactElement, + /** A ref to the rendered element of this node. */ + nodeRef?: ForwardedRef } export interface CollectionBranchProps extends HTMLAttributes { @@ -168,8 +172,8 @@ export const DefaultCollectionRenderer: DefaultRenderer = { CollectionBranch({collection, parent, renderDropIndicator}) { return useCollectionRender(collection, parent, renderDropIndicator); }, - CollectionNode({node, before, after}) { - return <>{before}{node.render!(node)}{after}; + CollectionNode({before = [], node, render = node.render, nodeRef, after = []}) { + return <>{...before}{render!(node, nodeRef)}{...after}; } }; @@ -190,13 +194,13 @@ function useCollectionRender( return <>; } - let pseudoProps = {}; + let pseudoProps: Pick>, 'before' | 'after'> = {}; if (renderDropIndicator && node.type === 'item') { - pseudoProps = { - before: renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'}), - after: renderAfterDropIndicators(collection, node, renderDropIndicator) - }; + let beforeIndicator = renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'}); + let afterIndicator = renderAfterDropIndicators(collection, node, renderDropIndicator); + + pseudoProps = {before: new Array(beforeIndicator), after: new Array(afterIndicator)}; } return ; diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 0336e312bcc..adfa970cf71 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -629,6 +629,7 @@ class TreeItemNode extends CollectionNode { */ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, (props: TreeItemProps, ref: ForwardedRef, item: Node) => { let state = useContext(TreeStateContext)!; + let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); ref = useObjectRef(ref); let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; @@ -733,10 +734,10 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, { - switch (item.type) { + children: node => { + switch (node.type) { case 'content': { - return item.render!(item); + return ; } // Skip item since we don't render the nested rows as children of the parent row, the flattened collection // will render them each as siblings instead @@ -744,7 +745,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, ; default: - throw new Error('Unsupported element type in TreeRow: ' + item.type); + throw new Error('Unsupported element type in TreeRow: ' + node.type); } } }); diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 31b777f8247..088ff5365eb 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -10,11 +10,11 @@ * governing permissions and limitations under the License. */ -import {CollectionBranchProps, CollectionNodeProps, CollectionRenderer, CollectionRendererContext, CollectionRootProps, renderAfterDropIndicators} from './Collection'; +import {CollectionBranchProps, CollectionNodeProps, CollectionRenderer, CollectionRendererContext, CollectionRootProps, DefaultCollectionRenderer, renderAfterDropIndicators} from './Collection'; import {DropTargetDelegate, ItemDropTarget, Node} from '@react-types/shared'; import {Layout, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; import {mergeProps} from '@react-aria/utils'; -import React, {createContext, JSX, ReactNode, useContext, useMemo} from 'react'; +import React, {createContext, ForwardedRef, JSX, ReactNode, useContext, useMemo} from 'react'; import {useScrollView, VirtualizerItem} from '@react-aria/virtualizer'; type View = ReusableView, ReactNode>; @@ -54,14 +54,15 @@ const LayoutContext = createContext(null); export function Virtualizer(props: VirtualizerProps): JSX.Element { let {children, layout: layoutProp, layoutOptions} = props; let layout = useMemo(() => typeof layoutProp === 'function' ? new layoutProp() : layoutProp, [layoutProp]); + let {CollectionNode: CtxCollectionNode} = useContext(CollectionRendererContext); let renderer: CollectionRenderer, ReactNode>> = useMemo(() => ({ isVirtualized: true, layoutDelegate: layout, dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined, CollectionRoot, CollectionBranch, - CollectionNode - }), [layout]); + CollectionNode: CtxCollectionNode + }), [CtxCollectionNode, layout]); return ( @@ -78,9 +79,8 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat let state = useVirtualizerState({ layout, collection, - renderView: (type, item) => { - return item?.render?.(item); - }, + // If we don't skip rendering the node here, we end up rendering them twice. + renderView: () => null, onVisibleRectChange(rect) { let element = scrollRef?.current; if (element) { @@ -120,38 +120,31 @@ function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) return useRenderChildren(parentView, Array.from(parentView.children), renderDropIndicator); } -function CollectionNode({node, parent, before, after, ...props}: CollectionNodeProps, ReactNode>>) { - return ( - <> - {before} - - {node.rendered} - - {after} - - ); -} - function useRenderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget) => ReactNode) { - let {CollectionNode: Item = CollectionNode} = useContext(CollectionRendererContext); + let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); return children.map(node => { let {collection, layout} = node.virtualizer; - let pseudoProps = {}; + let pseudoProps: Pick, ReactNode>>, 'before' | 'after'> = {}; if (layout.getDropTargetLayoutInfo && renderDropIndicator && node.content?.type === 'item') { - pseudoProps = { - before: renderDropIndicatorWrapper(parent, node, {type: 'item', key: node.content!.key, dropPosition: 'before'}, renderDropIndicator), - after: renderAfterDropIndicators(collection, node.content, target => renderDropIndicatorWrapper(parent, node, target, renderDropIndicator)) - }; + let beforeIndicator = renderDropIndicatorWrapper(parent, node, {type: 'item', key: node.content!.key, dropPosition: 'before'}, renderDropIndicator); + let afterIndicator = renderAfterDropIndicators(collection, node.content, target => renderDropIndicatorWrapper(parent, node, target, renderDropIndicator)); + + pseudoProps = {before: new Array(beforeIndicator), after: new Array(afterIndicator)}; } - return ; + let render = (item: Node, itemRef?: ForwardedRef) => ( + + {item.render!(item, itemRef)} + + ); + + return ; }); } From 21ca3889ad675b2e1f5b52576824a4274db4668f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Fri, 20 Feb 2026 17:48:27 +0100 Subject: [PATCH 4/6] chore: naming --- packages/react-aria-components/src/Virtualizer.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 088ff5365eb..4051b788099 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -54,15 +54,15 @@ const LayoutContext = createContext(null); export function Virtualizer(props: VirtualizerProps): JSX.Element { let {children, layout: layoutProp, layoutOptions} = props; let layout = useMemo(() => typeof layoutProp === 'function' ? new layoutProp() : layoutProp, [layoutProp]); - let {CollectionNode: CtxCollectionNode} = useContext(CollectionRendererContext); + let {CollectionNode} = useContext(CollectionRendererContext); let renderer: CollectionRenderer, ReactNode>> = useMemo(() => ({ isVirtualized: true, layoutDelegate: layout, dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined, CollectionRoot, CollectionBranch, - CollectionNode: CtxCollectionNode - }), [CtxCollectionNode, layout]); + CollectionNode + }), [CollectionNode, layout]); return ( From c42a639a4fd56e3e4d508bbbb133688d75439708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Fri, 20 Feb 2026 18:15:03 +0100 Subject: [PATCH 5/6] fix: remove spread operator --- packages/react-aria-components/src/Collection.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 79b9046a2ee..ce1b3a51e1d 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -173,7 +173,13 @@ export const DefaultCollectionRenderer: DefaultRenderer = { return useCollectionRender(collection, parent, renderDropIndicator); }, CollectionNode({before = [], node, render = node.render, nodeRef, after = []}) { - return <>{...before}{render!(node, nodeRef)}{...after}; + return ( + <> + {before.map((el, i) => {el})} + {render!(node, nodeRef)} + {after.map((el, i) => {el})} + + ); } }; From 09c8ffe746960fcb61baa5e8a7159a7d88d4d8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Fri, 20 Feb 2026 18:20:33 +0100 Subject: [PATCH 6/6] fix: tests in react 16 --- .../@react-aria/collections/test/CollectionBuilder.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 728a5274f06..b85b102789b 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -18,7 +18,7 @@ const SectionOld = createBranchComponent('section', (props, ref) => { return
; }); -const renderItems = (items, spyCollection, children = jest.fn()) => ( +const renderItems = (items, spyCollection, children = () => null) => ( {items.map((item) => )}}> {collection => { spyCollection.current = collection; @@ -27,7 +27,7 @@ const renderItems = (items, spyCollection, children = jest.fn()) => ( ); -const renderItemsOld = (items, spyCollection, children = jest.fn()) => ( +const renderItemsOld = (items, spyCollection, children = () => null) => (