Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom collection renderers (e.g. virtualization) #5912

Merged
merged 14 commits into from
May 25, 2024

Conversation

devongovett
Copy link
Member

@devongovett devongovett commented Feb 20, 2024

This adds support for custom collection renderers to React Aria Components, which enables features like virtualized scrolling where a subset of the collection is rendered to the DOM. Other features like breadcrumb and action group collapsing can also be built as collection renderers.

A collection renderer is a function that is provided to a component via context, which means it wraps around a collection component. For example, to make a ListBox virtualized, you would wrap it in a <Virtualizer>:

<Virtualizer>
  <ListBox>
    <ListBoxItem>Foo</ListBoxItem>
  </ListBox>
</Virtualizer>

The Virtualizer component provides the CollectionRendererContext to the ListBox, which uses it to render the collection to DOM nodes. By default, CollectionRendererContext is set to a function that renders all items to the DOM (the same way as currently). This function accepts two arguments: the collection itself, and the parent node whose items should be rendered (e.g. in the case of sections). A virtualizer would then filter the list of items to include only visible items.

To enable this in a generic way, collection nodes now know how to render themselves to the DOM. This means that a CollectionRenderer does not need to know how to render specific nodes, it delegates back to the nodes themselves. This also means that custom implementations of collection components such as ListBoxItem can be created and work within an existing ListBox – the item components are no longer coupled to the parent components.

This works by creating two new APIs for building collection components:

  • createLeafComponent – creates a collection component that doesn't accept child nodes. You must specify a node type (e.g. item), and a function to render that node.
  • createBranchComponent – creates a collection component that expects children. It also accepts a node type and render function, but also accepts a function that returns the collection children (defaulting to useCollectionChildren).

The plan would be to eventually expose these APIs publicly so custom collection components can be built, but I'm not totally happy with these APIs yet. Feels like there could be something that might be able to combine them a bit more. Suggestions welcome.

@rspbot
Copy link

rspbot commented Feb 20, 2024


function SubmenuTriggerInner(props) {
let {item, parentMenuRef} = props;
export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigger', (props: SubmenuTriggerProps, ref: ForwardedRef<HTMLDivElement>, item) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SubmenuTrigger changed so that the state/hooks for submenus live at the trigger level and get passed down to the MenuItem via context rather than having two separate MenuItem implementations. This works due to ref merging by useContextProps.

@@ -117,7 +115,7 @@ export function UNSTABLE_useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, st

let submenuProps = {
id: overlayId,
'aria-label': node.textValue,
'aria-labelledby': submenuTriggerId,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This avoids needing to pass in the menu item node. In RAC's case, the node is the submenutrigger node instead.

@majornista
Copy link
Collaborator

majornista commented Feb 20, 2024

With virtualization, rendered option items should include aria-posinset and aria-setsize.

Also, when the heading for a group is scrolled out of view, the group will no longer have an accessibility name, which differs from the behavior in React-Spectrum ListBox.

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some initial things found from testing, will edit the comment as I continue my review

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Haven't looked at the internals yet, will revisit then. Jotting down issues found during a testing):

  • Noticed that PageUp/PageDown only advance focus by one item after the first initial "page" jump
  • Home/End don't seem to cause scroll position to move the the first/last item
  • focus is lost to the body of the page when you scroll the focused item out of view

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah in a real implementation the keyboard delegate would need to be overridden on the listbox and get set to the layout. I can probably do that in the story.

@devongovett
Copy link
Member Author

For reviewers, keep in mind that the demo virtualizer is not a full implementation yet, it's just a simple demo in the story. The PR is mainly about setting up the infrastructure to support custom collection renderers. More work will be needed for a full virtualizer implementation. But feel free to post the issues you run into!

layout={layout}
style={{height: 'inherit'}}
collection={collection}
shouldUseVirtualFocus>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this really have shouldUseVirtualFocus?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used that so that we don't apply a tabIndex to the virtualizer element. In RSP, the virtualizer is the outer most element (the one with role="listbox") so this made sense. But here, the virtualizer is rendered inside the listbox (it has role="presentation"), so it should not also have a tabIndex. Really virtualizer is duplicating the logic already handled by the listbox/selection hooks, so it isn't really necessary.

}
}
});
let renderer = useContext(CollectionRendererContext);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is grabbing from the CollectionRendererContext, what happens if you have nested components but the inner component shouldn't be virtualized? For instance, maybe a virtualized Table with non-virtualized Menus within?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could clear the context in "leaves"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a good point. another idea would be to change the API to provide collection renderers as a prop instead of context. The component would then have to propagate it to children via its own context, but that wouldn't apply to other components. I sort of liked the idea that virtualizer could just be a wrapper though. Not sure...

packages/react-aria-components/src/Table.tsx Show resolved Hide resolved
return <>{useCollectionChildren(props)}</>;
}

export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node: Node<T>) => JSX.Element) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mentioned you weren't happy about the api here for these, did you have any other specific concerns other than possibly combining them more? To me, using these for custom collections already feels like a pretty advanced use case that needs a deeper understanding of how the RAC collections work so not sure how to simplify them more tbh

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the input, we'd still like to eliminate the useShallowComponent since that name doesn't make a ton of sense. We'd discussed something like this in order to make a meaningfully named option, though haven't quite gotten the types worked out for it.

export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node: Node<T>) => JSX.Element, allowsOutsideCollection?: false);
export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node?: Node<T>) => JSX.Element, allowsOutsideCollection: true);
export function createLeafComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node?: Node<T>) => JSX.Element, allowsOutsideCollection: boolean = false) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I understand the desire to remove createShallowComponent() 👍.

Personally i am not a big fan of hiding things behind boolean flags, since they are hard to read and discover, but I also don’t have a better suggestion in mind at current.

PS: I must have accidentally deleted my previous comment, so excuse the messed up timeline.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we're not totally enamored with it, either. But we think it's better than createShallowComponent, it's descriptive and shows it's only allowed for a leaf. If anyone has a better idea, we're all ears.

No worries about the deleted comment.

}

function MenuSection<T>({section, className, style, parentMenuRef, ...otherProps}: MenuSectionProps<T>) {
function MenuSection<T>(section: Node<T>, props: SectionProps<T>, ref: ForwardedRef<HTMLElement>) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick to be consistent with argument order, the createBranch etc all do
props, ref, node
whereas this is node, props, ref

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yeah I changed this at the last minute, good point

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we align these? or does this just need to be resolved now?

@rspbot
Copy link

rspbot commented Feb 28, 2024

@@ -77,6 +78,7 @@ export class NodeValue<T> implements Node<T> {
node.firstChildKey = this.firstChildKey;
node.lastChildKey = this.lastChildKey;
node.props = this.props;
node.render = this.render;
return node;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This whole block could be replaced.

Suggested change
return node;
return Object.assign(node, this);

…n-renderers

# Conflicts:
#	packages/@react-aria/menu/src/useSubmenuTrigger.ts
#	packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx
#	packages/@react-spectrum/menu/src/SubmenuTrigger.tsx
#	packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx
#	packages/react-aria-components/src/Breadcrumbs.tsx
#	packages/react-aria-components/src/Menu.tsx
#	packages/react-aria-components/src/Table.tsx
#	packages/react-aria-components/stories/ListBox.stories.tsx
@devongovett devongovett self-assigned this May 7, 2024
@rspbot
Copy link

rspbot commented May 8, 2024

Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple questions from just looking at the code, I'll go test it out now

});
};

export const CollectionRendererContext = createContext<CollectionRenderer>(useDefaultCollectionRenderer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the renderer all we'll ever need back from this?
or do we want to make it an object in case we decide we want to send more information through this context?
I don't have an example of anything yet

}

function MenuSection<T>({section, className, style, parentMenuRef, ...otherProps}: MenuSectionProps<T>) {
function MenuSection<T>(section: Node<T>, props: SectionProps<T>, ref: ForwardedRef<HTMLElement>) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we align these? or does this just need to be resolved now?

@snowystinger
Copy link
Member

Initial testing looks good!

When the virtualizer updates the visible views, it triggers a re-render of the parent component (e.g. section) whose children changed. But we don't need to re-render the wrapper element, just the children. By splitting this into a separate component, we avoid re-rendering the parent when only its children changed.
@rspbot
Copy link

rspbot commented May 10, 2024

snowystinger
snowystinger previously approved these changes May 16, 2024
@devongovett
Copy link
Member Author

I realized I will need to do some significant refactoring to Virtualizer to support RAC, which is somewhat separate from this work to support custom collection renderers. I would like to land this change initially and follow up with that additional work next. Though there might be some additional changes to the collection renderer API, this is already useful on its own for other non-virtualizer cases (e.g. collapsing breadcrumbs).

@rspbot
Copy link

rspbot commented May 22, 2024

@rspbot
Copy link

rspbot commented May 22, 2024

snowystinger
snowystinger previously approved these changes May 22, 2024
@rspbot
Copy link

rspbot commented May 24, 2024

LFDanLu
LFDanLu previously approved these changes May 24, 2024
Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, did a quick sweep and the collection components seem to be working as expected. Also verified locally that leaf components properly error if rendered outside a collection except for Header. Definitely like how this has simplified the internals of many of the RAC components now that the collection nodes know how to render themselves

Comment on lines +46 to +47
/** A list of child tree item objects used when dynamically rendering the tree item children. */
childItems?: Iterable<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For posterity in case we need to find the related ticket/discussion: https://github.com/orgs/adobe/projects/19/views/4?filterQuery=rac+colle&pane=issue&itemId=24923448

We will want to possibly change TreeView to a more RAC Tree like api instead of mimicking the same api of other current RSP collection components (e.g. using TreeItemContent instead of <TreeViewItem childItems={item.childItems} ...>) which will let us get rid of this prop

…n-renderers

# Conflicts:
#	packages/react-aria-components/src/Breadcrumbs.tsx
@devongovett devongovett dismissed stale reviews from LFDanLu and snowystinger via 13475a1 May 25, 2024 05:08
@rspbot
Copy link

rspbot commented May 25, 2024

@rspbot
Copy link

rspbot commented May 25, 2024

## API Changes

unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any', access: 'private' }
unknown top level export { type: 'any', access: 'private' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'identifier', name: 'Column' }
unknown top level export { type: 'identifier', name: 'Column' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
undefined already in set

@react-aria/menu

AriaSubmenuTriggerProps

 AriaSubmenuTriggerProps {
   delay?: number = 200
   isDisabled?: boolean
-  node: RSNode<unknown>
   parentMenuRef: RefObject<HTMLElement>
   submenuRef: RefObject<HTMLElement>
   type?: 'dialog' | 'menu'
 }

it changed:

  • useSubmenuTrigger

@react-spectrum/tree

TreeViewItem

-TreeViewItem {
-  TreeViewItem?: boolean
-}
+

SpectrumTreeViewItemProps

-SpectrumTreeViewItemProps {
-  children: ReactNode
-  hasChildItems?: boolean
-}
+

undefined

-
+TreeViewItem<T extends {}> {
+  TreeViewItem?: boolean
+}

undefined

-
+SpectrumTreeViewItemProps<T extends {} = {}> {
+  childItems?: Iterable<{}>
+  children: ReactNode
+  hasChildItems?: boolean
+}

@react-stately/layout

ListLayout

 ListLayout<T> {
   allowDisabledKeyFocus: boolean
   buildChild: (Node<T>, number, number) => LayoutNode
   buildCollection: () => Array<LayoutNode>
+  buildHeader: (Node<T>, number, number) => LayoutNode
   buildItem: (Node<T>, number, number) => LayoutNode
   buildNode: (Node<T>, number, number) => LayoutNode
   buildSection: (Node<T>, number, number) => LayoutNode
   collection: Collection<Node<T>>
   disabledKeys: Set<Key>
   getContentSize: () => void
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
   getFinalLayoutInfo: (LayoutInfo) => void
   getFirstKey: () => Key | null
   getInitialLayoutInfo: (LayoutInfo) => void
   getKeyAbove: (Key) => Key | null
   getKeyBelow: (Key) => Key | null
   getKeyForSearch: (string, Key) => Key | null
   getKeyPageAbove: (Key) => Key | null
   getKeyPageBelow: (Key) => Key | null
   getLastKey: () => Key | null
   getLayoutInfo: (Key) => void
   getVisibleLayoutInfos: (Rect) => void
   isLoading: boolean
   isValid: (Node<T>, number) => void
   isVisible: (LayoutNode, Rect) => void
   updateItemSize: (Key, Size) => void
   updateLayoutNode: (Key, LayoutInfo, LayoutInfo) => void
   validate: (InvalidationContext<Node<T>, unknown>) => void
 }
 

@devongovett devongovett merged commit 2bda4c9 into main May 25, 2024
26 checks passed
@devongovett devongovett deleted the collection-renderers branch May 25, 2024 05:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

None yet

7 participants