diff --git a/.changeset/perf-filtered-action-list-deferred.md b/.changeset/perf-filtered-action-list-deferred.md new file mode 100644 index 00000000000..1b256992db6 --- /dev/null +++ b/.changeset/perf-filtered-action-list-deferred.md @@ -0,0 +1,10 @@ +--- +'@primer/react': patch +--- + +perf(FilteredActionList): Use useDeferredValue to improve typing responsiveness with large lists + +- Add `useDeferredValue` for items to defer expensive list re-rendering +- Keep text input immediately responsive during typing +- Use deferred items for rendering while maintaining immediate items for user interactions +- SelectPanel automatically inherits these performance improvements diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index fa5164ecc57..db86d1636d5 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -2,7 +2,7 @@ import type {ScrollIntoViewOptions} from '@primer/behaviors' import {scrollIntoView, FocusKeys} from '@primer/behaviors' import type {KeyboardEventHandler, JSX} from 'react' import type React from 'react' -import {forwardRef, useCallback, useEffect, useRef, useState} from 'react' +import {forwardRef, memo, useCallback, useDeferredValue, useEffect, useRef, useState} from 'react' import type {TextInputProps} from '../TextInput' import TextInput from '../TextInput' import {ActionList, type ActionListProps} from '../ActionList' @@ -24,6 +24,128 @@ import {clsx} from 'clsx' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} +const MappedActionListItem = forwardRef((item, ref) => { + // keep backward compatibility for renderItem + // escape hatch for custom Item rendering + if (typeof item.renderItem === 'function') return item.renderItem(item) + + const { + id, + description, + descriptionVariant, + text, + trailingVisual: TrailingVisual, + leadingVisual: LeadingVisual, + trailingText, + trailingIcon: TrailingIcon, + onAction, + children, + ...rest + } = item + + return ( + | React.KeyboardEvent) => { + if (typeof onAction === 'function') + onAction(item, e as React.MouseEvent | React.KeyboardEvent) + }} + data-id={id} + ref={ref} + {...rest} + > + {LeadingVisual ? ( + + + + ) : null} + {children} + {text} + {description ? {description} : null} + {TrailingVisual ? ( + + {typeof TrailingVisual !== 'string' && isValidElementType(TrailingVisual) ? ( + + ) : ( + TrailingVisual + )} + + ) : TrailingIcon || trailingText ? ( + + {trailingText} + {TrailingIcon && } + + ) : null} + + ) +}) + +/** + * Memoized component that renders the list items. + * Using React.memo allows React to skip re-rendering when deferredItems hasn't changed yet, + * keeping the input responsive during typing. + */ +interface FilteredActionListItemsProps { + deferredItems: ItemInput[] + groupMetadata?: GroupedListProps['groupMetadata'] + getItemListForEachGroup: (groupId: string, itemsList: ItemInput[]) => ItemInput[] + isInputFocused: boolean + renderItem?: RenderItemFn +} + +const FilteredActionListItems = memo( + ({deferredItems, groupMetadata, getItemListForEachGroup, isInputFocused, renderItem}) => { + let firstGroupIndex = 0 + + return ( + <> + {groupMetadata?.length + ? groupMetadata.map((group, index) => { + if (index === firstGroupIndex && getItemListForEachGroup(group.groupId, deferredItems).length === 0) { + firstGroupIndex++ // Increment firstGroupIndex if the first group has no items + } + return ( + + + {group.header?.title ? group.header.title : `Group ${group.groupId}`} + + {getItemListForEachGroup(group.groupId, deferredItems).map(({key: itemKey, ...item}, itemIndex) => { + const key = itemKey ?? item.id?.toString() ?? itemIndex.toString() + return ( + + ) + })} + + ) + }) + : deferredItems.map(({key: itemKey, ...item}, index) => { + const key = itemKey ?? item.id?.toString() ?? index.toString() + return ( + + ) + })} + + ) + }, +) + +FilteredActionListItems.displayName = 'FilteredActionListItems' + export interface FilteredActionListProps extends Partial>, ListPropsBase { loading?: boolean loadingType?: FilteredActionListLoadingType @@ -133,6 +255,7 @@ export function FilteredActionList({ ...listProps }: FilteredActionListProps): JSX.Element { const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') + const onInputChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value @@ -142,6 +265,10 @@ export function FilteredActionList({ [onFilterChange, setInternalFilterValue], ) + // Use deferred value for items to avoid blocking typing during list re-rendering + // This allows the input to remain responsive while the list is being re-rendered with new items + const deferredItems = useDeferredValue(items) + const inputAndListContainerRef = useRef(null) const listRef = useRef(null) @@ -163,19 +290,19 @@ export function FilteredActionList({ const selectAllLabelText = selectAllChecked ? 'Deselect all' : 'Select all' - const getItemListForEachGroup = useCallback( - (groupId: string) => { - const itemsInGroup = [] - for (const item of items) { - // Look up the group associated with the current item. - if (item.groupId === groupId) { - itemsInGroup.push(item) - } + // Helper function to get items in a specific group + // Takes itemsList as parameter to work with both immediate and deferred items + // Empty dependency array is correct since the function doesn't close over external variables + const getItemListForEachGroup = useCallback((groupId: string, itemsList: ItemInput[]) => { + const itemsInGroup = [] + for (const item of itemsList) { + // Look up the group associated with the current item. + if (item.groupId === groupId) { + itemsInGroup.push(item) } - return itemsInGroup - }, - [items], - ) + } + return itemsInGroup + }, []) const onInputKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -194,7 +321,7 @@ export function FilteredActionList({ let firstGroupIndex = 0 for (let i = 0; i < groupMetadata.length; i++) { - if (getItemListForEachGroup(groupMetadata[i].groupId).length > 0) { + if (getItemListForEachGroup(groupMetadata[i].groupId, items).length > 0) { break } else { firstGroupIndex++ @@ -329,7 +456,6 @@ export function FilteredActionList({ if (message) { return message } - let firstGroupIndex = 0 const actionListContent = ( - {groupMetadata?.length - ? groupMetadata.map((group, index) => { - if (index === firstGroupIndex && getItemListForEachGroup(group.groupId).length === 0) { - firstGroupIndex++ // Increment firstGroupIndex if the first group has no items - } - return ( - - - {group.header?.title ? group.header.title : `Group ${group.groupId}`} - - {getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, itemIndex) => { - const key = itemKey ?? item.id?.toString() ?? itemIndex.toString() - return ( - - ) - })} - - ) - }) - : items.map(({key: itemKey, ...item}, index) => { - const key = itemKey ?? item.id?.toString() ?? index.toString() - return ( - - ) - })} + ) @@ -448,66 +542,11 @@ export function FilteredActionList({ )} {/* @ts-expect-error div needs a non nullable ref */}
+ {/* eslint-disable-next-line react-hooks/refs -- getBodyContent conditionally accesses scrollContainerRef.current during render for loading indicator height calculation */} {getBodyContent()}
) } -const MappedActionListItem = forwardRef((item, ref) => { - // keep backward compatibility for renderItem - // escape hatch for custom Item rendering - if (typeof item.renderItem === 'function') return item.renderItem(item) - - const { - id, - description, - descriptionVariant, - text, - trailingVisual: TrailingVisual, - leadingVisual: LeadingVisual, - trailingText, - trailingIcon: TrailingIcon, - onAction, - children, - ...rest - } = item - - return ( - | React.KeyboardEvent) => { - if (typeof onAction === 'function') - onAction(item, e as React.MouseEvent | React.KeyboardEvent) - }} - data-id={id} - ref={ref} - {...rest} - > - {LeadingVisual ? ( - - - - ) : null} - {children} - {text} - {description ? {description} : null} - {TrailingVisual ? ( - - {typeof TrailingVisual !== 'string' && isValidElementType(TrailingVisual) ? ( - - ) : ( - TrailingVisual - )} - - ) : TrailingIcon || trailingText ? ( - - {trailingText} - {TrailingIcon && } - - ) : null} - - ) -}) FilteredActionList.displayName = 'FilteredActionList'