Skip to content

perf(core): hoist inline objects and callbacks in hot list components#3367

Open
orrgottlieb wants to merge 1 commit into
masterfrom
perf/hoist-inline-objects
Open

perf(core): hoist inline objects and callbacks in hot list components#3367
orrgottlieb wants to merge 1 commit into
masterfrom
perf/hoist-inline-objects

Conversation

@orrgottlieb

@orrgottlieb orrgottlieb commented May 21, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

Stops re-creating per-render objects and closures inside components that render per-item in lists. Each unstable reference forces memoized children to reconcile on every parent update; in a 100-row Combobox/Dropdown that allocation cost compounds on every keystroke.

Files changed

  • BaseItem{} / [] empty-object defaults replaced with frozen module-scoped constants (EMPTY_ITEM_PROPS, EMPTY_TOOLTIP_PROPS, EMPTY_ITEM, TEXT_TOOLTIP_PROPS).
  • ComboboxOptionuseMemo around style={{ height: optionLineHeight }}.
  • ComboboxHelpers.dividerItemRenderer — hoists the common-case { height: DIVIDER_HEIGHT } style.
  • DropdownBaseListuseMemo around style={{ maxHeight }}.
  • MenuItemIconuseMemo for the conditional { backgroundColor } style.
  • Tab — frozen EMPTY_TOOLTIP_PROPS constant; useCallback for onClick/onKeyDown.

Why

From a performance audit (item #8): inline style={{ }} and ={() => ...} in list-item renderers cause N allocations per parent render where N is the list length. The fix is mechanical (hoist or memoize) and zero-API-change.

Test plan

  • CI: tests pass for Combobox, Dropdown, Menu, Tabs, BaseItem
  • Visual parity in Storybook stories

🤖 Generated with Claude Code


PR Type

Enhancement


Description

  • Hoist inline objects and callbacks in list-item components

    • Replace empty-object defaults with frozen module-scoped constants
    • Wrap dynamic styles in useMemo to prevent per-render allocations
    • Convert inline handlers to useCallback in Tab component
  • Eliminates N allocations per parent render where N is list length

  • Improves performance in Combobox, Dropdown, Menu, and Tabs


Diagram Walkthrough

flowchart LR
  A["Per-render inline objects<br/>and callbacks"] -->|Hoist constants| B["Frozen module-scoped<br/>constants"]
  A -->|Memoize styles| C["useMemo wrapped<br/>style objects"]
  A -->|Memoize handlers| D["useCallback wrapped<br/>event handlers"]
  B --> E["Reduced allocations<br/>in list renders"]
  C --> E
  D --> E
  E --> F["Better performance<br/>in list components"]
Loading

File Walkthrough

Relevant files
Enhancement
BaseItem.tsx
Hoist empty objects to frozen constants                                   

packages/core/src/components/BaseItem/BaseItem.tsx

  • Created four frozen module-scoped constants: EMPTY_ITEM_PROPS,
    EMPTY_TOOLTIP_PROPS, EMPTY_ITEM, TEXT_TOOLTIP_PROPS
  • Replaced inline empty objects {} with frozen constants in default
    parameters
  • Replaced inline tooltip props object with TEXT_TOOLTIP_PROPS constant
+9/-4     
ComboboxHelpers.tsx
Hoist divider style to constant                                                   

packages/core/src/components/Combobox/ComboboxHelpers/ComboboxHelpers.tsx

  • Created DIVIDER_STYLE constant for the common-case divider height
    style
  • Added conditional logic in dividerItemRenderer to use hoisted style
    when height matches default
  • Falls back to fresh object only for non-default heights
+5/-1     
ComboboxOption.tsx
Memoize option height style object                                             

packages/core/src/components/Combobox/components/ComboboxOption/ComboboxOption.tsx

  • Wrapped style={{ height: optionLineHeight }} in useMemo hook
  • Memoized style object depends on optionLineHeight prop
+3/-1     
DropdownBaseList.tsx
Memoize dropdown list max-height style                                     

packages/core/src/components/Dropdown/components/DropdownBaseList/DropdownBaseList.tsx

  • Added useMemo import
  • Wrapped style={{ maxHeight: maxMenuHeight }} in useMemo hook
  • Memoized style object depends on maxMenuHeight prop
+3/-2     
MenuItemIcon.tsx
Memoize menu item icon background style                                   

packages/core/src/components/Menu/MenuItem/components/MenuItemIcon/MenuItemIcon.tsx

  • Added useMemo import
  • Created EMPTY_STYLE frozen constant for empty style object
  • Wrapped conditional backgroundColor style in useMemo hook
  • Converted component from arrow function to block body for hook usage
+30/-24 
Tab.tsx
Memoize tab event handlers and tooltip props                         

packages/core/src/components/Tabs/Tab/Tab.tsx

  • Added useCallback import
  • Created EMPTY_TOOLTIP_PROPS frozen constant for default tooltip props
  • Converted inline handleKeyDown function to useCallback with
    dependencies
  • Converted inline onClick handler to useCallback named handleClick
  • Replaced inline arrow function with handleClick callback reference
+18/-9   

Stops re-creating objects and closures in render for components that
are rendered per-item in lists (Combobox options, dropdown rows,
menu items, tabs). Each per-item allocation forces every memoized
child to re-reconcile on every parent update.

Specific changes:
- `BaseItem`: replace `{}` and `[]` empty-object defaults with frozen
  module-scope constants (`EMPTY_ITEM_PROPS`, `EMPTY_TOOLTIP_PROPS`,
  `EMPTY_ITEM`, `TEXT_TOOLTIP_PROPS`).
- `ComboboxOption`: wrap `style={{ height: optionLineHeight }}` in
  `useMemo`.
- `ComboboxHelpers.dividerItemRenderer`: hoist the common-case
  `style={{ height: DIVIDER_HEIGHT }}` to a constant.
- `DropdownBaseList`: `useMemo` the `style={{ maxHeight }}` object.
- `MenuItemIcon`: `useMemo` the conditional `{ backgroundColor }` style.
- `Tab`: replace `{} as TooltipProps` default with a frozen constant;
  `useCallback` the inline `onClick`/`onKeyDown` handlers.

Audit finding #8.
@orrgottlieb orrgottlieb requested a review from a team as a code owner May 21, 2026 23:06
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1)

Grey Divider


Remediation recommended

1. MenuItemIcon missing forwardRef 📘 Rule violation ⚙ Maintainability
Description
MenuItemIcon renders a DOM wrapper (Flex) but is implemented as a plain function component, so
consumers cannot attach a ref to its root element. This violates the requirement that DOM-rendering
components use React forwardRef for consistent ref forwarding.
Code

packages/core/src/components/Menu/MenuItem/components/MenuItemIcon/MenuItemIcon.tsx[R10-12]

Evidence
PR Compliance ID 3 requires DOM-rendering components to use React.forwardRef. In
MenuItemIcon.tsx, the component is declared as const MenuItemIcon = (...) => { ... } without
forwardRef, while returning a root Flex element, so refs cannot be forwarded.

CLAUDE.md: Components Must Use React forwardRef Pattern
packages/core/src/components/Menu/MenuItem/components/MenuItemIcon/MenuItemIcon.tsx[10-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`MenuItemIcon` is implemented as a plain function component, so refs cannot be forwarded to its root DOM element.

## Issue Context
The component renders a `Flex` wrapper (DOM output) and should follow the library pattern of using `React.forwardRef` for consistent ref forwarding.

## Fix Focus Areas
- packages/core/src/components/Menu/MenuItem/components/MenuItemIcon/MenuItemIcon.tsx[10-44]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@github-actions

Copy link
Copy Markdown
Contributor

📦 Bundle Size Analysis

✅ No bundle size changes detected.

Unchanged Components
Component Base PR Diff
@vibe/button 17.3KB 17.29KB -9B 🟢
@vibe/clickable 5.95KB 5.96KB +3B 🔺
@vibe/dialog 52.14KB 52.16KB +14B 🔺
@vibe/icon-button 66.09KB 66.1KB +13B 🔺
@vibe/icon 12.92KB 12.89KB -32B 🟢
@vibe/layer 2.96KB 2.96KB 0B ➖
@vibe/layout 9.82KB 9.83KB +11B 🔺
@vibe/loader 5.64KB 5.65KB +10B 🔺
@vibe/tooltip 61.33KB 61.32KB -7B 🟢
@vibe/typography 63.47KB 63.43KB -47B 🟢
Accordion 6.31KB 6.29KB -14B 🟢
AccordionItem 66.43KB 66.39KB -47B 🟢
AlertBanner 70.83KB 70.9KB +71B 🔺
AlertBannerButton 18.76KB 18.76KB -2B 🟢
AlertBannerLink 15.26KB 15.26KB +4B 🔺
AlertBannerText 63.95KB 63.91KB -38B 🟢
AttentionBox 74.35KB 74.29KB -61B 🟢
Avatar 66.84KB 66.72KB -119B 🟢
AvatarGroup 93.29KB 93.39KB +106B 🔺
Badge 43.19KB 43.17KB -24B 🟢
BreadcrumbItem 64.7KB 64.64KB -61B 🟢
BreadcrumbMenu 68.57KB 68.58KB +12B 🔺
BreadcrumbMenuItem 77.07KB 77.16KB +86B 🔺
BreadcrumbsBar 5.68KB 5.68KB -1B 🟢
ButtonGroup 68.32KB 68.3KB -17B 🟢
Checkbox 66.83KB 66.92KB +99B 🔺
Chips 75.05KB 75.01KB -36B 🟢
ColorPicker 74.47KB 74.45KB -26B 🟢
ColorPickerContent 73.73KB 73.7KB -28B 🟢
Combobox 84.08KB 84KB -82B 🟢
Counter 42.21KB 42.28KB +65B 🔺
DatePicker 112.41KB 112.37KB -39B 🟢
Divider 5.42KB 5.46KB +44B 🔺
Dropdown 95.35KB 95.3KB -52B 🟢
EditableHeading 66.63KB 66.53KB -104B 🟢
EditableText 66.46KB 66.42KB -38B 🟢
EmptyState 70.48KB 70.39KB -97B 🟢
ExpandCollapse 66.22KB 66.19KB -32B 🟢
FormattedNumber 5.86KB 5.84KB -13B 🟢
GridKeyboardNavigationContext 4.65KB 4.65KB -4B 🟢
HiddenText 5.4KB 5.39KB -15B 🟢
Info 72.06KB 72.06KB +3B 🔺
Label 68.65KB 68.67KB +21B 🔺
Link 14.91KB 14.88KB -30B 🟢
List 72.88KB 72.84KB -43B 🟢
ListItem 65.54KB 65.49KB -59B 🟢
ListItemAvatar 66.88KB 66.92KB +40B 🔺
ListItemIcon 13.97KB 13.97KB +5B 🔺
ListTitle 65.02KB 64.95KB -69B 🟢
Menu 8.65KB 8.64KB -19B 🟢
MenuDivider 5.56KB 5.57KB +7B 🔺
MenuGridItem 7.16KB 7.19KB +36B 🔺
MenuItem 76.95KB 76.93KB -20B 🟢
MenuItemButton 70.11KB 70.06KB -55B 🟢
MenuTitle 65.35KB 65.34KB -11B 🟢
MenuButton 66.08KB 66.14KB +57B 🔺
Modal 79.14KB 79.06KB -84B 🟢
ModalContent 4.72KB 4.71KB -1B 🟢
ModalHeader 65.79KB 65.77KB -19B 🟢
ModalMedia 7.51KB 7.5KB -3B 🟢
ModalFooter 67.72KB 67.68KB -35B 🟢
ModalFooterWizard 68.6KB 68.56KB -40B 🟢
ModalBasicLayout 8.96KB 8.9KB -57B 🟢
ModalMediaLayout 8.08KB 8.06KB -19B 🟢
ModalSideBySideLayout 6.3KB 6.29KB -4B 🟢
MultiStepIndicator 52.96KB 52.95KB -11B 🟢
NumberField 72.87KB 72.87KB -6B 🟢
ProgressBar 7.34KB 7.35KB +7B 🔺
RadioButton 65.9KB 65.9KB +3B 🔺
Search 70.65KB 70.61KB -46B 🟢
Skeleton 6KB 6.01KB +4B 🔺
Slider 73.86KB 73.84KB -19B 🟢
SplitButton 66.48KB 66.52KB +46B 🔺
SplitButtonMenu 8.8KB 8.76KB -34B 🟢
Steps 71.31KB 71.36KB +57B 🔺
Table 7.26KB 7.25KB -13B 🟢
TableBody 66.68KB 66.69KB +11B 🔺
TableCell 65.22KB 65.26KB +36B 🔺
TableContainer 5.31KB 5.32KB +16B 🔺
TableHeader 5.64KB 5.64KB +1B 🔺
TableHeaderCell 72.2KB 72.15KB -43B 🟢
TableRow 5.56KB 5.55KB -8B 🟢
TableRowMenu 68.87KB 68.85KB -27B 🟢
TableVirtualizedBody 71.42KB 71.38KB -39B 🟢
Tab 64KB 64.04KB +34B 🔺
TabList 8.89KB 8.86KB -30B 🟢
TabPanel 5.3KB 5.29KB -14B 🟢
TabPanels 5.86KB 5.86KB -2B 🟢
TabsContext 5.48KB 5.51KB +29B 🔺
TextArea 66.26KB 66.25KB -3B 🟢
TextField 69.43KB 69.43KB -2B 🟢
TextWithHighlight 64.35KB 64.3KB -48B 🟢
ThemeProvider 4.36KB 4.36KB -1B 🟢
Tipseen 71.17KB 71.15KB -21B 🟢
TipseenContent 71.6KB 71.6KB -2B 🟢
TipseenMedia 71.27KB 71.3KB +26B 🔺
TipseenWizard 73.93KB 73.8KB -137B 🟢
Toast 74.1KB 73.94KB -168B 🟢
ToastButton 18.59KB 18.62KB +33B 🔺
ToastLink 15.05KB 15.08KB +31B 🔺
Toggle 66.62KB 66.59KB -27B 🟢
TransitionView 5.42KB 5.45KB +30B 🔺
VirtualizedGrid 12.54KB 12.54KB +2B 🔺
VirtualizedList 12.28KB 12.26KB -12B 🟢
List (Next) 8.17KB 8.16KB -15B 🟢
ListItem (Next) 69.88KB 69.89KB +12B 🔺
ListTitle (Next) 65.31KB 65.29KB -21B 🟢

📊 Summary:

  • Total Base Size: 4.75MB
  • Total PR Size: 4.75MB
  • Total Difference: 1.25KB

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant