Skip to content
Open
Show file tree
Hide file tree
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
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 @@ -164,22 +164,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 +169 to +179
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 @@ -320,6 +320,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 featureFlagDisplayInViewportInsideDialog = useFeatureFlag(
'primer_react_action_menu_display_in_viewport_inside_dialog',
)
Expand Down Expand Up @@ -351,17 +365,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