Skip to content

perf(ActionList): memoize context values, menuItemProps, aria attributes#7554

Open
hectahertz wants to merge 6 commits intomainfrom
hectahertz/perf-action-list-memoization
Open

perf(ActionList): memoize context values, menuItemProps, aria attributes#7554
hectahertz wants to merge 6 commits intomainfrom
hectahertz/perf-action-list-memoization

Conversation

@hectahertz
Copy link
Contributor

@hectahertz hectahertz commented Feb 15, 2026

Closes #

Every parent re-render created 8 new objects per ActionList.Item, including new context values that prevented React from bailing out of child renders. This stabilizes context values and computed props.

Changes:

  • Memoize ListContext.Provider and ItemContext.Provider values with useMemo
  • Memoize menuItemProps object (rebuilt per item per render)
  • Memoize aria-labelledby and aria-describedby computation
  • Hoist baseSlots, slotsConfig, selectableRoles, listRoleTypes to module scope (were recreated per item per render)
  • Replace array allocation per keypress with direct comparison
  • Convert mutable let to const ternary (React Compiler compatibility)

Metrics

Object allocation reduction (microbenchmark, Chrome):

Metric Old New (deps unchanged) New (deps changed)
Objects per Item render 8 0 3
Objects per 100-item re-render 800 0 300
Objects per 100 re-renders x 100 items 80,000 0 30,000
Allocation time (10K items) 4.70 ms 0.10 ms -

Changelog

New

N/A

Changed

  • ActionList: memoized context provider values, menuItemProps, and aria attribute computation
  • ActionList.Item: hoisted static config objects to module scope

Removed

N/A

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

  1. Open the ActionList stress test in Storybook (StressTests/Components/ActionList)
  2. All 115 ActionList, ActionMenu, and NavList tests pass

Merge checklist

  • Added/updated tests
  • Added/updated documentation
  • Added/updated previews (Storybook)
  • Changes are SSR compatible
  • Tested in Chrome
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge
  • (GitHub staff only) Integration tests pass at github/github

@changeset-bot
Copy link

changeset-bot bot commented Feb 15, 2026

🦋 Changeset detected

Latest commit: 79d7a58

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Feb 15, 2026
Copy link
Contributor Author

@hectahertz hectahertz left a comment

Choose a reason for hiding this comment

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

Walkthrough of the conceptual changes in this PR, grouped by optimization type.


const slotsConfig = {...baseSlots, description: Description}

// Pre-allocated array for selectableRoles check, avoids per-render allocation
Copy link
Contributor Author

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, and listRoleTypes are 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).

}

const [partialSlots, childrenWithoutSlots] = useSlots(props.children, {...baseSlots, description: Description})
const [partialSlots, childrenWithoutSlots] = useSlots(props.children, slotsConfig)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pass stable slot config reference

Previously: useSlots(props.children, {...baseSlots, description: Description}) created a new object via spread on every render. Now it receives the stable module-level slotsConfig.

(event: React.KeyboardEvent<HTMLElement>) => {
if (disabled || inactive || loading) return
if ([' ', 'Enter'].includes(event.key)) {
if (event.key === ' ' || event.key === 'Enter') {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Avoid array allocation on keypress

Replaced [' ', 'Enter'].includes(event.key) with direct === comparison. The array was allocated on every keypress event handler invocation.

if (showInactiveIndicator) {
focusable = true
}
const focusable = showInactiveIndicator ? true : undefined
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Convert mutable let to const ternary

The old let focusable + if block was flagged by the React Compiler as a mutable dependency that couldn't be preserved in memoization. Converting to a const ternary fixes React Compiler compatibility and is more concise.

role: itemRole,
id: itemId,
}
const hasTrailingVisualSlot = Boolean(slots.trailingVisual)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Memoize aria attribute strings

Previously, aria-labelledby was built with template literals that always produced a new string (including trailing spaces from empty slots). aria-describedby used .filter(String).join(' ').trim() which allocates intermediate arrays on every render.

Now both are computed in useMemo with explicit conditionals. Boolean deps (hasTrailingVisualSlot, hasDescriptionSlot) are derived above to keep the dep array stable and avoid depending on slot object references.


const ariaDescribedBy = React.useMemo(() => {
const parts: string[] = []
if (hasDescriptionSlot && descriptionVariant === 'block') parts.push(blockDescriptionId)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Memoize menuItemProps object

This object is created per item and passed to either the <li> (via containerProps) or the <ItemWrapper> (via wrapperProps). Previously rebuilt on every render even when no values changed. Now memoized with all relevant deps.

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. clickHandler, keyPressHandler, itemRole, itemId rarely change).

@@ -238,18 +263,21 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
ref: forwardedRef,
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Memoize ItemContext.Provider value

Previously, the inline object passed to ItemContext.Provider was recreated on every render, causing all context consumers (Description, LeadingVisual, TrailingVisual, Selection) to re-render even when their relevant values hadn't changed.

Now wrapped in useMemo with 7 deps. In a 100-item list, this prevents up to ~400 unnecessary child component re-renders per parent state change.

listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined,
})

const listContextValue = React.useMemo(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Memoize ListContext.Provider value

Same pattern as ItemContext. The inline object was recreated on every ActionList render, causing all ActionList.Item children to re-render due to context referential inequality, even when variant, selectionVariant, showDividers, role, and headingId hadn't changed.

@hectahertz hectahertz changed the title perf(ActionList): memoize context values, menuItemProps, aria attributes [pending metrics] perf(ActionList): memoize context values, menuItemProps, aria attributes Feb 15, 2026
@github-actions github-actions bot temporarily deployed to storybook-preview-7554 February 15, 2026 11:33 Inactive
@hectahertz hectahertz force-pushed the hectahertz/perf-action-list-memoization branch from 844a7bd to 3eb5a4a Compare February 15, 2026 11:43
@github-actions github-actions bot temporarily deployed to storybook-preview-7554 February 15, 2026 11:47 Inactive
@github-actions github-actions bot temporarily deployed to storybook-preview-7554 February 15, 2026 11:58 Inactive
@hectahertz hectahertz marked this pull request as ready for review February 18, 2026 18:23
@hectahertz hectahertz requested a review from a team as a code owner February 18, 2026 18:23
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes ActionList performance by memoizing context values and computed props to prevent unnecessary re-renders. The changes address a performance issue where every parent re-render created 8 new objects per ActionList.Item, including new context values that prevented React from bailing out of child renders.

Changes:

  • Memoized React context provider values (ListContext and ItemContext) using useMemo to prevent unnecessary child re-renders
  • Memoized computed props (menuItemProps, ariaLabelledBy, ariaDescribedBy) to avoid recreating objects on every render
  • Hoisted static configuration objects (baseSlots, slotsConfig, selectableRoles, listRoleTypes) to module scope to avoid per-render allocation
  • Replaced array allocation in keypress check with direct string comparison for micro-optimization
  • Converted mutable let focusable to immutable const ternary for React Compiler compatibility

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/react/src/ActionList/List.tsx Memoized ListContext.Provider value to stabilize context and prevent unnecessary consumer re-renders
packages/react/src/ActionList/Item.tsx Hoisted static configs to module scope; memoized ItemContext.Provider value, menuItemProps, and ARIA attribute computations; optimized keypress handler and focusable variable
.changeset/perf-action-list-memoization.md Added changeset documenting the performance improvements as a patch release

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments