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

RFC: update slots API to allow arrays #19266

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions rfcs/convergence/array-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
RFC: Array slots

---

@smhigley

## Summary

This RFC proposes modifying the Slots API to accept arrays of slot props to support components like Combobox or List, where the most ergonomic authoring experience is passing in an array of data rather than unique JSX children.
Copy link
Member

Choose a reason for hiding this comment

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

This is how we started in Semantic UI React and we decided to use the same API in Northstar, we call it shorthand collections. When we started there were no other way to pass props from parent to children components:

From our POV this API worked well as it's really simple:

// 🦁 wow, so easy!
<Dropdown items={["Robert Tolbert", "Wanda Howard", "Tim Deboer"]} />

However, usage in complex applications that are our main audience is different: data comes from different sources, lists are huge and there strict performance requirements. Below is a summary of feedback that we received from Northstar customers.

Keys are hard

First of all, customers should use keys (which is not usually obvious in examples):

<>
  // 🟥 if we will use these strings a keys, we will get a React warning about items with duplicate keys
  <Dropdown items={["Robert", "Robert"]} />
  // ✅ will not produce warnings
  <Dropdown
    items={[
      { key: "robert-k", content: "Robert" },
      { key: "robert-t", content: "Robert" }
    ]}
  />
</>

There is no easy option how generate keys for customers in automated way, they should pass it. Check a comment from @levithomason in Semantic-Org/Semantic-UI-React#2418 (comment), it's a good summary from library POV.

Performance issues (memoization)

"My items are really expensive to render, how I memoize them?"

Items can really expensive to re-render especially in components like Dropdown, Menu, List & etc. A simple Avatar from Teams is below, it's even without status 😱

image

One of the frequent asks that we had was about memoization, we have an entry in our FAQ. Developers want to implement their features and expect that library will work intuitively for them. But it does not seem to be true from an example below:

const MemoizedItem = React.memo(Menu.Item, (prevProps, nextProps) => {
  return prevProps.active === nextProps.active;
});

const renderBlocker = (Component, props) => <MemoizedItem {...props} />;

function App() {
  return (
    <>
      {/* 👎 Even with `renderItem()` it will not be better */}
      <Menu
        items={[
          { content: "One", key: "one", children: renderBlocker },
          { content: "Two", key: "two", children: renderBlocker }
        ]}
      />

      {/* 👍 This looks like in React docs */}
      <Menu>
        <MemoizedItem>One</MemoizedItem>
        <MemoizedItem>Two</MemoizedItem>
      </Menu>
    </>
  );
}

Performance issues (redundant rendering)

Another feedback that we received is related to a fact that customers should compute their items in advance for menus (or dropdowns, etc.) even before they are visible.

It could be cheap to get data for the items itself, but each item can contain a lot of additional info required to render: user's state, description, etc. For example:

<>
  <Dropdown
    items={[
      {
        // 👇 "key" & "content" are required for rendering
        key: "robert-t",
        content: "Robert Tolbert",
        // 👇 "image" & "description" could be fetched later, but we don't allow this with "items" 😕
        image: "/avatar/RobertTolbert.jpg",
        description: "Software Engineer"
      }
    ]}
  />

  <Dropdown>
    {/* 👇 only minimal data to render "Dropdown" */}
    {[{ key: "robert-t", content: "Robert Tolbert" }].map(item => (
      // 👍 Data can be fetched lazily inside custom component
      <DropdownItemThatFetchesDetails {...item} />
    ))}
  </Dropdown>
</>

A short demo of this behavior is available on this CodeSandbox:

  • for v0 we should call getUserStatus() before a menu is opened
  • for v9 we call getUserStatus() only when a menu is opened

Performance issues (root of update is moved to the parent)

Another issue that we met is related to a fact that a parent component is a root of rendering. It's highly coupled to a previous section. See an example below:

<>
  <Dropdown
    // 👇 "online" is a property of an item, if we will change it, Dropdown should be re-rendered *anyway*
    // 💥 Other items will be also re-rendered by default, to avoid this we will need to use memoization
    items={[{ key: "robert-t", content: "Robert Tolbert", status: "online" }]}
  />

  <Dropdown>
    {[{ key: "robert-t", content: "Robert Tolbert" }].map(item => (
      // 😅 As "status" is computed *inside* an item, only it will be updated
      <DropdownItemWithStatus {...item} />
    ))}
  </Dropdown>
</>

Demo of this behavior on CodeSandbox, check output in console:

// with "items"
render:DropdownA
render:DropdownItemA Jane
render:DropdownItemA John
// with JSX
render:DropdownItemBJane

Bundle size penalty

If I don't use DropdownItem from Fluent, it will be bundled anyway with Dropdown as it calls it internally:

<>
  <Dropdown
    // 👇 somewhere internally we need to render "DropdownItem", it's a direct dependency
    //    and will be bundled anyway
    items={[{ key: "robert-t", content: "Robert Tolbert", status: "online" }]}
  />

  <Dropdown>
    {/* 👇 Can be absolutely custom and used custom styles without penalties */}
    <CustomItem />
  </Dropdown>
</>

Virtualization

Components like Dropdown (Combobox) usually have filtering (client search) on client-side this means that input items are different from output items:

image
image

This means that virtualization component should be put inside Dropdown to know about its state, so we will need to add more props only for it:

<>
  <Dropdown
    items={items}
    renderItem={item => <MemoizedItem {...item} />}
    renderItems={(
      jsxItems /* items from "renderItem()" */,
      rawItems /* complex lists can require item objects */
    ) => (
      <List height={150} itemCount={items.length} itemSize={35} width={300}>{items}</List>
    )}
  >{/* 💡 this should be inside */}
  </Dropdown>
  {/* VS. */}
  <Dropdown><List height={150} itemCount={items.length} itemSize={35} width={300}>{MemoizedItem}</List>
  </Dropdown>
</>

It's one of reasons why we refactored Tree component in Northstar to hooks, it was to complex and not enough customisable.

BTW, a I don't think that we should include virtualization into components by default:

  • our customers has own solutions
  • it will bundle size penalty for components

Libraries that were designed after React hooks appeared also use JSX children in the most cases:

This happened mainly because of React Context & hooks, these APIs simplified sharing data & handlers between parent and children components. We also use use-context-selector to avoid redundant updates in child components, there is a good showcase for it in Northstar docs (only items with updated state are re-rendered).


For components like Combobox I suggest to keep going with JSX items. We will meet edge cases like Tree component where items are nested and it will be impossible to use JSX items, but I am considering this as an exception rather than a rule.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the detailed comment! The issues with performance and memoization are great to bring up. I'm curious about the best way to approach some of the issues I ran into when implementing Combobox with children, especially now that the Descendants API has been removed.

Essentially, the biggest issue is that the parent control (or a parent context) needs to be aware of the number of child options present, as well as their key/id values so that the active item can be controlled. The parent is the one that receives the keyboard events, and the one that needs to set the current item's id in an attribute, so it needs the ability to get the previous/next/first/last/etc child's id in response to user interaction.

Theoretically this could work with React.children, as long as it's a flat list. The issue there is something like this:

<Combobox>
  <Group name="fruits">
    <Option>apple</Option>
    <Option>banana</Option>
  </Group>
  <Group name="vegetables">
    <Option>onion</Option>
    <Option>zucchini</Option>
  </Group>
</Combobox>

Potentially we could do something like create a context that gives each option the ability to register/deregister itself by id, though it would be difficult to ensure we have the ids in the correct order. There's maybe also something we can do with React.Children in both Combobox and Group, so Combobox's context ends up with the correct set of current options. No matter what we do, the parent is going to need to update any time the descendants array updates, just based on how activedescendant works. There's likely a clever solution to this somewhere that I haven't been able to think of yet 😊.

For memoization, since we're dealing with slot arrays, I also wonder if authoring like this might work:

const MemoOption = React.memo(Option);

<Combobox options=[
  <MemoOption props={...etc} />
  <MemoOption props={...etc} />
  <MemoOption props={...etc} />
] />

I need to test it more, but it seems like that might still allow delayed updates and allowing the author to update one child without affecting the others. I'm definitely not confident on that approach yet, I'd definitely need to test it more (or you might know offhand if there's a reason it wouldn't fly). It definitely makes managing Combobox's state easier though 😅.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Virtualization is also a really interesting case, since I think even with options as children, the virtualization provider would likely need to live inside Combobox as a slot, since Combobox itself also needs to know what the total number of options is vs. the rendered number.

Copy link
Member

Choose a reason for hiding this comment

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

Chatted @smhigley and @behowell about this RFC. After pondering a bit, I think having a slot that takes an array of literals, props objects, or Components is what I would expect as consistent with singular slots in other components. A common scenario will be to pass in the array of props and be able to have a Component that is given each of the those props as a template. What if there was an itemAs property that behaved like as (literal tag name, component, or render function) that would be used for each item? This extends the as metaphor from single slots to a slot with an array.

Copy link
Member

Choose a reason for hiding this comment

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

the virtualization provider would likely need to live inside Combobox as a slot, since Combobox itself also needs to know what the total number of options is vs. the rendered number.

With the v9 hooks/render func composition architecture, I would have expected users to simply create their own component variants and wrap children with the virtualization provider


## Problem Statement

So far, all slot usage in vNext components assumes the desired authoring experience is to define slots or children one at a time. That makes sense for the components we've created so far, since none of them render a set of elements that are usually generated from an array of data. Even for Menu, which contains a list of MenuItem children, it's not common for authors to want to convert an array of repetitive data into menu items.

In contrast, Combobox and Listbox patterns do commonly take an array of data and use it to render mostly uniform items. The same is likely going to be true of components like List (if we have one) and DetailsList/Grid. For those controls, good authoring ergonomics lean more towards this:

```js
<Combobox options={['apple', 'banana', 'cucumber', ...etc]} />
Copy link
Member

Choose a reason for hiding this comment

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

Many combobox/dropdown examples that I have seen in Teams, were not this library friendly. Most items could not just be mapped to simple strings, but also required icons, specific layout changes, styles overrides etc..

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ling1726 thanks for the feedback! Does it help at all that options is a slot array and not a string array? It means items could be authored as strings, slot objects, or JSX elements without needing an onRenderX function.

Copy link
Member

Choose a reason for hiding this comment

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

For the RFC I would propose that it also provides some code samples of these more complex options content

```

Than this:

```js
<Combobox>
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 update this example to showcase usage? IMO this comparison will be more accurate:

const options = ["apple", "banana", "cucumber"];

function App() {
  return (
    <>
      <Combobox options={options} />
      {/* VS. */}
      <Combobox>
        {options.map(item => (
          <Option key={item}>{item}</Option>
        ))}
      </Combobox>
    </>
  );
}

It's very similar to what React docs are showing: https://reactjs.org/docs/lists-and-keys.html#basic-list-component

Copy link
Member

@ling1726 ling1726 Aug 9, 2021

Choose a reason for hiding this comment

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

The map does not need to be rendered directly as children, this will now resemble the shorthand proposal quite closely, and allows easy memoization

const options = ["apple", "banana", "cucumber"];

function App() {
  const comboboxItems = options.map(item => (<Option key={item.id}>{item.name}</Option>))
  return (
    <>
      <Combobox>
        {comboboxItems}
      </Combobox>
    </>
  );
}

Copy link
Member

Choose a reason for hiding this comment

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

I prefer this approach to having two props of items and onRenderItems which is what we did in v8. It's effectively the same thing, but the mapping is done by the user instead of the component.

<Option>apple</Option>
<Option>banana</Option>
<Option>cluster fig</Option>
[...etc]
</Combobox>
```

Accepting an array of slot data isntead of children will also make it easier to internally handle virtualization in the future.

## Detailed Proposal

Modify `getSlots` and `resolveShorthand` to allow authors to pass in an array of slot data for certain slots.

Taking Combobox as an example, the authoring experience would look like this:

```js
// using strings to define the slots:
<Combobox options={['apple', 'banana', 'chokecherry']} icon={<Chevron />} ..etc />

// using slot props
<Combobox options={optionData.map((option, i) => ({ 'data-index': i, children: option.name, title: 'tooltips are awful' }))} icon={<Chevron />} ..etc />
```

The Combobox component would then handle `options` like this:

within `renderCombobox`:

```js
const { slots, slotProps } = getSlots < ComboboxSlots > (state, ['options']);
return (
<slots.root {...slotProps.root}>
{slots.options.map((Option, i) => {
return <Option key={`option-${i}`} {...slotProps.options[i]} />;
Copy link
Member

Choose a reason for hiding this comment

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

Using Index as a key is an anti-pattern in React:

We don’t recommend using indexes for keys if the order of items may change. This can negatively impact performance and may cause issues with component state. Check out Robin Pokorny’s article for an in-depth explanation on the negative impacts of using an index as a key. If you choose not to assign an explicit key to list items then React will default to using indexes as keys.

https://reactjs.org/docs/lists-and-keys.html#keys
https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318

})}
</slots.root>
);
```

within `useCombobox`:

```js
const state: ComboboxState = {
role: 'combobox',
tabIndex: 0,
...etc,
components: {
root: 'div',
options: 'div',
Copy link
Contributor

Choose a reason for hiding this comment

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

One drawback here is that you can't specify a different component for each entry in the array. In this case, every option is a <div>, and there's no way to make certain ones be a <GroupHeader> component or something.

This comment is mostly an FYI because I think I have a decent solution that modifies how this components prop works. I'm going to write an RFC with this proposal, but a quick summary:

// Currently requires a components={...} object separate from the slot value
<MyButton components={{ label: MyLabel }} label="foo" />
<MyButton components={{ label: 'span' }} label={{ className: 'baz', children: 'bar' }} />

// Proposal: slots could accept a tuple of [ type, shorthandValue ]
<MyButton label={[ MyLabel, 'foo' ]} />
<MyButton label={[ 'span', { className: 'baz', children: 'bar' } ]} />

That would naturally allow a different component to be specified per slot in this RFC:

<Combobox options={[ 
  [GroupHeader, 'Fruits'],
  'apple',
  'banana',
  [GroupHeader, 'Vegetables'],
  'carrot',
  'daikon',
]} />

},
options: resolveShorthandArray(props.options, {
role: 'option',
...etc,
}),
};
```

Then `resolveShorthandArray` would be exported from `resolveShorthand.ts` as something like this:

```js
export function resolveShorthandArray<Props extends Record<string, any>>(
value: ShorthandProps<Props>[],
Copy link
Contributor

Choose a reason for hiding this comment

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

You'd also want to accept undefined | null here:

Suggested change
value: ShorthandProps<Props>[],
value: ShorthandProps<Props>[] | undefined | null,

defaultProps?: Props,
): ObjectShorthandProps<Props>[] {
if (value === null || !Array.isArray(value)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

If you take my suggestion above, you'd also need to check for undefined (or just falsy). Also, (nit) remove the isArray check; that should be handled by typescript.

Suggested change
if (value === null || !Array.isArray(value)) {
if (!value) {

return [] as ObjectShorthandProps<Props>[];
}

return value.map((singleValue) => resolveShorthand(singleValue, defaultProps));
}
```

`getSlots` would be modified to handle array slots by adding a check to see if the slot is an array:

```js
if (Array.isArray(slot) {
slots[name] = slot.map((subSlot) => {
// run through the same logic getSlots currently uses to standardize slot data
})
}
```
One downside to this solution is it that in order for an author to define a set of props that appear on every option, they'll need to duplicate that prop object for each option in `props.options`. We could potentially consider adding another prop like `defaultOption: React.HTMLAttributes<HTMLElement>` to make that easier.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's ok to start by requiring authors to manually apply a prop to each entry for now (e.g. using map). If it becomes a problem, we could add another way of doing it in the future.

## Discarded Solutions
Another option I looked into was keeping the slot itself singular, and rendering it inside a loop:
In `renderCombobox`:
```js
const { slots, slotProps } = getSlots < ComboboxSlots > (state, ['option']);
// resolveOptionProps is a way to add option-specific props like aria-selected or id based on the index
const { optionData, resolveOptionProps } = state;

return (
<slots.root {...slotProps.root}>
{optionData.map((option, i) => {
return <slots.option key={`option-${i}`} {...slotProps.option} {...resolveOptionProps(option, i)} />;
})}
</slots.root>
);
```
Then `useCombobox` handles `option` and `resolveOptionProps` something like this:
```js
const state: ComboboxState = {
role: 'combobox',
tabIndex: 0,
...etc,
components: {
root: 'div',
options: 'div',
},
options: resolveShorthand(props.option, {
role: 'option',
...etc,
}),
};

const resolveOptionProps = React.useCallback(
(option: string | Object, index: number) => {
return resolveShorthand(props.option, {
id: `${id}-${index}`,
'aria-selected': index === activeIndex,
//simplified code, but this allows a custom per-option render:
children: props.renderOption ? props.renderOption(option) : option,
});
},
[activeIndex],
);
state.resolveOptionProps = resolveOptionProps;
```
The authoring experience would look like this:
```js
<Combobox option={{ 'some-default-prop-on-all-options': 'test' }} data={['apple', 'banana', 'cauliflower']}>
Copy link
Member

Choose a reason for hiding this comment

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

Having been a web-dev using OUIF, this kind of shorthand API was extremely unfriendly to deal with especially when you need to support features like:

  • Async loading
  • Special actions rerendering a specific item
  • Slightly altering specific items but not all

OUIF was very well known to have a lot of onRenderX callbacks or specific onXLifecycle methods to support this kind of shorthand API, which not only resulted in more code, but actually required mappings from our own application state to specific component lifecycle methods

```
The downside here is it leans back towards custom `renderX` props, and doesn't seem in the spirit of the slot pattern.