-
Notifications
You must be signed in to change notification settings - Fork 650
perf(ActionList): memoize context values, menuItemProps, aria attributes #7554
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
base: main
Are you sure you want to change the base?
Changes from all commits
7d3a3e6
38e18ea
d97ff12
3eb5a4a
c0acd03
79d7a58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@primer/react': patch | ||
| --- | ||
|
|
||
| perf(ActionList): memoize context values, menuItemProps, and aria attributes |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,19 @@ const DivItemContainerNoBox = React.forwardRef<HTMLDivElement, React.HTMLAttribu | |
| }, | ||
| ) | ||
|
|
||
| const baseSlots = { | ||
| leadingVisual: LeadingVisual, | ||
| trailingVisual: TrailingVisual, | ||
| trailingAction: TrailingAction, | ||
| subItem: SubItem, | ||
| } | ||
|
|
||
| const slotsConfig = {...baseSlots, description: Description} | ||
|
|
||
| // Pre-allocated array for selectableRoles check, avoids per-render allocation | ||
| const selectableRoles = ['menuitemradio', 'menuitemcheckbox', 'option'] | ||
| const listRoleTypes = ['listbox', 'menu', 'list'] | ||
|
|
||
| const UnwrappedItem = <As extends React.ElementType = 'li'>( | ||
| { | ||
| variant = 'default', | ||
|
|
@@ -69,14 +82,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>( | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| forwardedRef: React.Ref<any>, | ||
| ): JSX.Element => { | ||
| const baseSlots = { | ||
| leadingVisual: LeadingVisual, | ||
| trailingVisual: TrailingVisual, | ||
| trailingAction: TrailingAction, | ||
| subItem: SubItem, | ||
| } | ||
|
|
||
| const [partialSlots, childrenWithoutSlots] = useSlots(props.children, {...baseSlots, description: Description}) | ||
| const [partialSlots, childrenWithoutSlots] = useSlots(props.children, slotsConfig) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass stable slot config reference Previously: |
||
|
|
||
| const slots = {description: undefined, ...partialSlots} | ||
|
|
||
|
|
@@ -150,7 +156,6 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>( | |
| itemRole === 'menuitemcheckbox' || | ||
| itemRole === 'tab' | ||
|
|
||
| const listRoleTypes = ['listbox', 'menu', 'list'] | ||
| const listSemantics = (listRole && listRoleTypes.includes(listRole)) || inactive || listItemSemantics | ||
| const buttonSemantics = !listSemantics && !_PrivateItemWrapper | ||
|
|
||
|
|
@@ -165,7 +170,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>( | |
| const keyPressHandler = React.useCallback( | ||
| (event: React.KeyboardEvent<HTMLElement>) => { | ||
| if (disabled || inactive || loading) return | ||
| if ([' ', 'Enter'].includes(event.key)) { | ||
| if (event.key === ' ' || event.key === 'Enter') { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid array allocation on keypress Replaced |
||
| if (event.key === ' ') { | ||
| event.preventDefault() // prevent scrolling on Space | ||
| // immediately reset defaultPrevented once its job is done | ||
|
|
@@ -189,41 +194,61 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>( | |
|
|
||
| const ItemWrapper = _PrivateItemWrapper || DefaultItemWrapper | ||
|
|
||
| // only apply aria-selected and aria-checked to selectable items | ||
| const selectableRoles = ['menuitemradio', 'menuitemcheckbox', 'option'] | ||
| const includeSelectionAttribute = itemSelectionAttribute && itemRole && selectableRoles.includes(itemRole) | ||
|
|
||
| let focusable | ||
|
|
||
| if (showInactiveIndicator) { | ||
| focusable = true | ||
| } | ||
| const focusable = showInactiveIndicator ? true : undefined | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Convert mutable The old |
||
|
|
||
| // Extract the variant prop value from the description slot component | ||
| const descriptionVariant = slots.description?.props.variant ?? 'inline' | ||
|
|
||
| const menuItemProps = { | ||
| onClick: clickHandler, | ||
| onKeyPress: !buttonSemantics ? keyPressHandler : undefined, | ||
| 'aria-disabled': disabled ? true : undefined, | ||
| 'data-inactive': inactive ? true : undefined, | ||
| 'data-loading': loading && !inactive ? true : undefined, | ||
| tabIndex: focusable ? undefined : 0, | ||
| 'aria-labelledby': `${labelId} ${slots.trailingVisual ? trailingVisualId : ''} ${ | ||
| slots.description && descriptionVariant === 'inline' ? inlineDescriptionId : '' | ||
| }`, | ||
| 'aria-describedby': | ||
| [ | ||
| slots.description && descriptionVariant === 'block' ? blockDescriptionId : undefined, | ||
| inactiveWarningId ?? undefined, | ||
| ] | ||
| .filter(String) | ||
| .join(' ') | ||
| .trim() || undefined, | ||
| ...(includeSelectionAttribute && {[itemSelectionAttribute]: selected}), | ||
| role: itemRole, | ||
| id: itemId, | ||
| } | ||
| const hasTrailingVisualSlot = Boolean(slots.trailingVisual) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Memoize aria attribute strings Previously, Now both are computed in |
||
| const hasDescriptionSlot = Boolean(slots.description) | ||
|
|
||
| const ariaLabelledBy = React.useMemo(() => { | ||
| const parts = [labelId] | ||
| if (hasTrailingVisualSlot) parts.push(trailingVisualId) | ||
| if (hasDescriptionSlot && descriptionVariant === 'inline') parts.push(inlineDescriptionId) | ||
| return parts.join(' ') | ||
| }, [labelId, hasTrailingVisualSlot, trailingVisualId, hasDescriptionSlot, descriptionVariant, inlineDescriptionId]) | ||
|
|
||
| const ariaDescribedBy = React.useMemo(() => { | ||
| const parts: string[] = [] | ||
| if (hasDescriptionSlot && descriptionVariant === 'block') parts.push(blockDescriptionId) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Memoize This object is created per item and passed to either the Note: this has a large dep array (14 deps) because the object touches many item-level values. The memoization still pays off because in typical usage most deps are stable across re-renders (e.g. |
||
| if (inactiveWarningId) parts.push(inactiveWarningId) | ||
| return parts.length > 0 ? parts.join(' ') : undefined | ||
| }, [hasDescriptionSlot, descriptionVariant, blockDescriptionId, inactiveWarningId]) | ||
|
|
||
| const menuItemProps = React.useMemo( | ||
| () => ({ | ||
| onClick: clickHandler, | ||
| onKeyPress: !buttonSemantics ? keyPressHandler : undefined, | ||
| 'aria-disabled': disabled ? true : undefined, | ||
| 'data-inactive': inactive ? true : undefined, | ||
| 'data-loading': loading && !inactive ? true : undefined, | ||
| tabIndex: focusable ? undefined : 0, | ||
| 'aria-labelledby': ariaLabelledBy, | ||
| 'aria-describedby': ariaDescribedBy, | ||
| ...(includeSelectionAttribute && {[itemSelectionAttribute]: selected}), | ||
| role: itemRole, | ||
| id: itemId, | ||
| }), | ||
| [ | ||
| clickHandler, | ||
| buttonSemantics, | ||
| keyPressHandler, | ||
| disabled, | ||
| inactive, | ||
| loading, | ||
| focusable, | ||
| ariaLabelledBy, | ||
| ariaDescribedBy, | ||
| includeSelectionAttribute, | ||
| itemSelectionAttribute, | ||
| selected, | ||
| itemRole, | ||
| itemId, | ||
| ], | ||
| ) | ||
|
|
||
| const containerProps = _PrivateItemWrapper | ||
| ? {role: itemRole ? 'none' : undefined, ...props} | ||
|
|
@@ -238,18 +263,21 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>( | |
| ref: forwardedRef, | ||
| } | ||
|
|
||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Memoize Previously, the inline object passed to Now wrapped in |
||
| const itemContextValue = React.useMemo( | ||
| () => ({ | ||
| variant, | ||
| size, | ||
| disabled, | ||
| inactive: Boolean(inactiveText), | ||
| inlineDescriptionId, | ||
| blockDescriptionId, | ||
| trailingVisualId, | ||
| }), | ||
| [variant, size, disabled, inactiveText, inlineDescriptionId, blockDescriptionId, trailingVisualId], | ||
| ) | ||
|
|
||
| return ( | ||
| <ItemContext.Provider | ||
| value={{ | ||
| variant, | ||
| size, | ||
| disabled, | ||
| inactive: Boolean(inactiveText), | ||
| inlineDescriptionId, | ||
| blockDescriptionId, | ||
| trailingVisualId, | ||
| }} | ||
| > | ||
| <ItemContext.Provider value={itemContextValue}> | ||
| <li | ||
| {...containerProps} | ||
| ref={listSemantics ? forwardedRef : null} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,16 +55,19 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>( | |
| listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined, | ||
| }) | ||
|
|
||
| const listContextValue = React.useMemo( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Memoize Same pattern as |
||
| () => ({ | ||
| variant, | ||
| selectionVariant: selectionVariant || containerSelectionVariant, | ||
| showDividers, | ||
| role: listRole, | ||
| headingId, | ||
| }), | ||
| [variant, selectionVariant, containerSelectionVariant, showDividers, listRole, headingId], | ||
| ) | ||
|
|
||
| return ( | ||
| <ListContext.Provider | ||
| value={{ | ||
| variant, | ||
| selectionVariant: selectionVariant || containerSelectionVariant, | ||
| showDividers, | ||
| role: listRole, | ||
| headingId, | ||
| }} | ||
| > | ||
| <ListContext.Provider value={listContextValue}> | ||
| {slots.heading} | ||
| {/* @ts-expect-error ref needs a non nullable ref */} | ||
| <Component | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hoist static objects to module scope
baseSlots,slotsConfig,selectableRoles, andlistRoleTypesare constant across all renders and all Item instances. Previously they were recreated inside the component body on every render for every item. Moving them to module scope eliminates N object allocations per render (where N = number of items in the list).