Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
5 changes: 5 additions & 0 deletions .changeset/perf-action-menu-memo-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Memoize ActionMenu context values to prevent unnecessary re-renders of menu items
56 changes: 30 additions & 26 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,22 +173,22 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
}
})

return (
<MenuContext.Provider
value={{
anchorRef,
renderAnchor,
anchorId,
open: combinedOpenState,
onOpen,
onClose,
// will be undefined for the outermost level, then false for the top menu, then true inside that
isSubmenu: parentMenuContext.isSubmenu !== undefined,
}}
>
{contents}
</MenuContext.Provider>
const isSubmenu = parentMenuContext.isSubmenu !== undefined

const menuContextValue = useMemo(
() => ({
anchorRef,
renderAnchor,
anchorId,
open: combinedOpenState,
onOpen,
onClose,
isSubmenu,
}),
[anchorRef, renderAnchor, anchorId, combinedOpenState, onOpen, onClose, isSubmenu],
Comment on lines +178 to +188
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

menuContextValue will still change on every render because renderAnchor is reassigned to a newly-created inline function during the React.Children.map(...) pass. Since renderAnchor is in the useMemo deps, this memoization won’t prevent re-renders of MenuContext consumers in the common case. Consider memoizing renderAnchor (and/or the contents computation that sets it) so the function identity is stable when children/anchorRef haven’t changed, otherwise the useMemo here provides little/no benefit.

Copilot uses AI. Check for mistakes.
)

return <MenuContext.Provider value={menuContextValue}>{contents}</MenuContext.Provider>
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -330,6 +330,20 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({
}
}, [anchorRef])

const afterSelect = useCallback(() => onClose?.('item-select'), [onClose])

const overlayContextValue = useMemo(
() => ({
container: 'ActionMenu' as const,
listRole: 'menu' as const,
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
selectionAttribute: 'aria-checked' as const,
afterSelect,
enableFocusZone: isNarrowFullscreen,
}),
[ariaLabelledby, anchorAriaLabelledby, anchorId, afterSelect, isNarrowFullscreen],
)

const featureFlagMaxHeightClampToViewport = useFeatureFlag('primer_react_overlay_max_height_clamp_to_viewport')

const isInsideDialog = useContext(DialogContext) !== undefined
Expand Down Expand Up @@ -358,17 +372,7 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({
{...(overlayProps.overflow ? {[`data-overflow-${overlayProps.overflow}`]: ''} : {})}
{...(overlayProps.maxHeight ? {[`data-max-height-${overlayProps.maxHeight}`]: ''} : {})}
>
<ActionListContainerContext.Provider
value={{
container: 'ActionMenu',
listRole: 'menu',
// If there is a custom aria-labelledby, use that. Otherwise, if exists, use the id that labels the anchor such as tooltip. If none of them exist, use anchor id.
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
selectionAttribute: 'aria-checked', // Should this be here?
afterSelect: () => onClose?.('item-select'),
enableFocusZone: isNarrowFullscreen, // AnchoredOverlay takes care of focus zone. We only want to enable this if menu is narrow fullscreen.
}}
>
<ActionListContainerContext.Provider value={overlayContextValue}>
{children}
</ActionListContainerContext.Provider>
</div>
Expand Down
Loading