Skip to content

fix(Popper): memoize content subtree updates#2639

Open
yan-ad wants to merge 4 commits into
unovue:v2from
yan-ad:fix/combobox-popper-rerenders
Open

fix(Popper): memoize content subtree updates#2639
yan-ad wants to merge 4 commits into
unovue:v2from
yan-ad:fix/combobox-popper-rerenders

Conversation

@yan-ad
Copy link
Copy Markdown
Contributor

@yan-ad yan-ad commented May 12, 2026

🔗 Linked issue

Resolves #2415

❓ Type of change

  • 📖 Documentation (updates to the documentation, readme or JSdoc annotations)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

This PR reduces unnecessary rerenders for PopperContent consumers, specifically the ComboboxContent position="popper" case described in #2415.

When Floating UI recalculates popper placement, the wrapper needs to update positioning/style metadata, but the inner content subtree does not need to rerender unless the props that affect it actually change. This memoizes the inner Primitive subtree so heavy slot content, such as large combobox option lists, is not repeatedly rerendered during popper position updates.

A regression test was added for ComboboxContent position="popper" with 60 options to ensure the option slot content does not rerender excessively after opening and running popper position updates.

📸 Screenshots (if appropriate)

Not applicable.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

Summary by CodeRabbit

  • Tests

    • Added tests validating combobox popper behavior and slot-render stability, ensuring filtering updates visible options while keeping re-renders within expected bounds.
  • Refactor

    • Introduced targeted memoization to popper-positioned content and combobox items to reduce unnecessary re-renders and improve responsiveness.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ec4f54b-ac73-4f52-a216-07f4905b7ac6

📥 Commits

Reviewing files that changed from the base of the PR and between 6aeacad and 6bad46b.

📒 Files selected for processing (3)
  • packages/core/src/Combobox/Combobox.test.ts
  • packages/core/src/Combobox/ComboboxItem.vue
  • packages/core/src/Popper/PopperContent.vue
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/core/src/Combobox/Combobox.test.ts
  • packages/core/src/Combobox/ComboboxItem.vue
  • packages/core/src/Popper/PopperContent.vue

📝 Walkthrough

Walkthrough

Adds memoization hooks: a new memoDependencies prop and v-memo usage in PopperContent, per-item v-memo in ComboboxItem, conditional memo-dependency wiring in ComboboxContentImpl for popper mode, and tests asserting bounded slot re-renders and correct filtering while popper is open.

Changes

Popper re-render performance fix

Layer / File(s) Summary
PopperContent API: memoDependencies prop
packages/core/src/Popper/PopperContent.vue
Adds an optional memoDependencies?: unknown[] prop to control reactive invalidation inputs for the memoized content subtree.
PopperContent memoization directive
packages/core/src/Popper/PopperContent.vue
The Primitive wrapper now includes a v-memo keyed on props.asChild, props.as, placement/positioning (placedSide/placedAlign, isPositioned), all incoming $attrs, and props.memoDependencies; :as is bound to props.as.
ComboboxItem per-item memoization
packages/core/src/Combobox/ComboboxItem.vue
Adds v-memo to ListboxItem to memoize individual item renders using isRender, disabled state, props.value, props.as, props.asChild, and current $attrs.
ComboboxContentImpl: pass memo-dependencies when using popper
packages/core/src/Combobox/ComboboxContentImpl.vue
When position === 'popper', passes [rootContext.filterSearch.value, rootContext.filterState.value] as :memo-dependencies; otherwise passes undefined.
Regression tests for popper slot render and filtering
packages/core/src/Combobox/Combobox.test.ts
Consolidates Vue imports and adds tests that mount a Popper-based Combobox, spy on the options slot render count (stubbing ResizeObserver), assert the render count remains bounded after open and ticks, and verify filtering while open updates visible options correctly.

Sequence Diagram(s)

sequenceDiagram
  participant TestHarness
  participant Combobox
  participant PopperContent
  participant ListboxItem
  TestHarness->>Combobox: mount & open()
  Combobox->>PopperContent: render/popover attach
  PopperContent->>PopperContent: compute placement (floating-ui)
  alt placement updates
    PopperContent->>PopperContent: update internal placement state
    PopperContent->>ListboxItem: (v-memo prevents full re-render) skip re-render when keys unchanged
  else filter input while open
    Combobox->>PopperContent: memoDependencies change (filterSearch/filterState)
    PopperContent->>ListboxItem: re-render affected items
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰
I hopped into the Popper's den to spy,
Counted renders as the floating winds blew by,
With memo keys set and tiny renders spared,
My list danced once — then quiet, calm, and paired.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning While the PR implements memoization to reduce rerenders, it has unresolved concerns about $attrs handling and slot content staleness during filtering operations that undermine full compliance with issue #2415 objectives. Address the $attrs stability issue by including spread attribute values in memo dependencies or moving memoization closer to heavy consumers; add filtering test to validate slot content updates work correctly during popper interactions.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(Popper): memoize content subtree updates' directly and concisely describes the main change—adding memoization to PopperContent to prevent unnecessary rerenders.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing memoization for PopperContent and integrating it into ComboboxContent, ComboboxItem, and tests—no extraneous modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

npm i https://pkg.pr.new/reka-ui@2639

commit: 6bad46b

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/Popper/PopperContent.vue`:
- Line 406: The template references an undefined identifier "as" in the v-memo
and :as binding; update both to use the component prop via props.as (consistent
with props.asChild usage) so the v-memo becomes v-memo="[props.asChild,
props.as, placedSide, placedAlign, isPositioned, $attrs]" and the element's :as
binding uses props.as; this fixes the undefined variable in PopperContentProps
(which extends PrimitiveProps) and keeps prop access consistent across the
template.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7dd9bb6d-3fa5-4ac1-8b91-714a9cff2393

📥 Commits

Reviewing files that changed from the base of the PR and between edbeb93 and 5f7faf0.

📒 Files selected for processing (2)
  • packages/core/src/Combobox/Combobox.test.ts
  • packages/core/src/Popper/PopperContent.vue

Comment thread packages/core/src/Popper/PopperContent.vue Outdated
@zernonia
Copy link
Copy Markdown
Member

Nice fix direction! Two concerns though:

  1. v-memo caches the entire subtree, including slot vnodes. So when deps don't change, dynamic slot content goes stale. e.g. typing in a Combobox to filter options doesn't change placedSide/placedAlign/isPositioned, so users would see stale options. The added test only opens the popper — could you add one that filters while open?

  2. $attrs in the deps is a no-op. instance.attrs is a stable proxy reference, so Object.is never sees it change. Since PopperContent has inheritAttrs: false and forwards attrs to the inner Primitive, dynamic attrs go stale too — e.g. ComboboxContentImpl's :data-empty won't update on filter. Either spread attr values (...Object.values($attrs)), or move the memo closer to the heavy consumer (e.g. inside Combobox).

Also toBeLessThanOrEqual(2) could be exact, and the fake ResizeObserver skips the auto-update loop this is actually optimizing 🙂

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/core/src/Combobox/Combobox.test.ts (1)

583-587: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

ResizeObserver mock currently prevents Floating UI’s resize-driven autoUpdate cycles from running in this test.

packages/core/src/Popper/PopperContent.vue wires whileElementsMounted: autoUpdate(...) without disabling elementResize, and @floating-ui/vue’s autoUpdate uses ResizeObserver to trigger update on reference/floating size changes. The no-op mock in packages/core/src/Combobox/Combobox.test.ts (observe/unobserve/disconnect do nothing) means those ResizeObserver-triggered position recalculations won’t happen, so the regression may not exercise the repeated positioning updates memoization is meant to guard against.

Use a ResizeObserver mock that captures the callback and triggers it (or otherwise forces autoUpdate to run multiple update cycles), so the test actually covers the memoization behavior under size-change-driven placement updates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/Combobox/Combobox.test.ts` around lines 583 - 587, The
current ResizeObserver mock in Combobox.test.ts is a no-op and prevents
`@floating-ui/vue`'s autoUpdate (wired via whileElementsMounted in
PopperContent.vue) from running resize-driven update cycles; replace the mock
with one that stores the callback passed to its constructor and invokes it (or
schedules repeated invocations) when observe is called so autoUpdate's update
loop runs multiple times and triggers the placement recalculations; ensure the
mock implements observe/unobserve/disconnect and calls the stored callback with
a entries array (or at least invokes it) to simulate size changes so the
memoization behavior is actually exercised.
packages/core/src/Combobox/ComboboxItem.vue (1)

83-101: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

v-memo may freeze custom option slot content that reads filterSearch reactively.

The deps [isRender, rootContext.disabled.value, disabled, props.value, props.as, props.asChild, ...Object.values($attrs)] cover the props forwarded to ListboxItem and the disabled contract correctly. Highlight/selection still update because they're driven by ListboxItem's own reactive context (the parent's v-memo doesn't block a child's own render effect).

The gap is the slot: a consumer that renders search-aware content (e.g., bolding the matched substring of rootContext.filterSearch) will go stale if isRender doesn't change when filterSearch updates. For example, if isRender is already true and remains true when the search term changes, v-memo will skip the re-render. The default <slot>{{ value }}</slot> is fine, but custom slots that read rootContext.filterSearch reactively will not update.

Add rootContext.filterSearch.value to the v-memo dependency array.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/Combobox/ComboboxItem.vue` around lines 83 - 101, The
v-memo dependency array in ComboboxItem.vue is missing
rootContext.filterSearch.value which can cause custom slot content that reads
rootContext.filterSearch to become stale; update the v-memo deps (the array used
on the ListboxItem element) to include rootContext.filterSearch.value so slot
render updates when the search term changes, keeping the existing deps
(isRender, rootContext.disabled.value, disabled, props.value, props.as,
props.asChild, ...Object.values($attrs)) intact.
packages/core/src/Popper/PopperContent.vue (1)

409-432: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Unconditional v-memo on PopperContent will serve stale slot content to every consumer except Combobox in popper mode.

v-memo caches the entire memoized subtree—including the <slot /> vnodes—and skips re-invoking the slot function when the dependency array is unchanged. This directive is unconditionally applied to the shared Primitive wrapper for all PopperContent consumers (Popover, Select, HoverCard, Menu, etc.), which means any consumer whose slot content reacts to state not captured by the deps (placedSide, placedAlign, isPositioned, $attrs values, memoDependencies) will render stale content.

Currently, only ComboboxContent with position="popper" supplies memoDependencies (filterSearch.value and filterState.value). All other consumers—PopoverContent, SelectContent, HoverCardContent, MenuContent—rely solely on placement and attribute changes, exposing them to stale slot rendering when their content depends on other reactive state.

Concrete example: A PopoverContent containing <p>{{ message }}</p> will not update when message changes, because none of the memo dependencies change.

Recommend scoping the optimization to the Combobox consumer (e.g., wrap v-memo in ComboboxContentImpl or a dedicated wrapper) so generic PopperContent slot dynamics remain live. This avoids silent regressions across the component library.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/Popper/PopperContent.vue` around lines 409 - 432, The
unconditional v-memo on the Primitive inside PopperContent caches slot vnodes
and causes stale slot renders for all consumers; restrict the memoization to
only the Combobox popper case by removing or gating the v-memo in PopperContent
and moving/applying it only in the Combobox-specific implementation (e.g.,
ComboboxContentImpl or ComboboxContent when position="popper"), or at minimum
only enable v-memo when props.memoDependencies is present/non-empty; update the
Primitive usage in PopperContent (and add the v-memo to ComboboxContentImpl) so
other consumers render live slot content.
🧹 Nitpick comments (2)
packages/core/src/Combobox/Combobox.test.ts (1)

600-600: ⚡ Quick win

Tighten test thresholds to exact assertions.

The current thresholds (toBeLessThanOrEqual(4)) are loose and may not catch subtle regressions in render count. Using exact expected values would make the test more precise and easier to reason about.

Consider replacing the threshold checks with exact assertions (e.g., toBe(2) or toBe(3)) once you've determined the actual expected render count with the memoization in place.

Also applies to: 605-605

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/Combobox/Combobox.test.ts` at line 600, The test uses loose
thresholds (toBeLessThanOrEqual) for render counts; replace those with exact
assertions after confirming the real expected counts with current memoization.
Locate the assertions referencing renderCountAfterOpen (and the analogous
assertion at the second occurrence) in Combobox.test.ts and change
toBeLessThanOrEqual(...) to toBe(expectedNumber) using the actual measured
render count (e.g., toBe(2) or toBe(3)) so the test asserts an exact render
count rather than a range.
packages/core/src/Popper/PopperContent.vue (1)

411-419: 💤 Low value

...Object.values($attrs) can defeat memoization via identity-unstable attrs.

Spreading attr values is the correct fix for the stable-proxy problem (where $attrs itself never compares unequal). However, forwarded $attrs frequently include event handlers and freshly-constructed objects/styles whose identity changes on every parent render. Each such value makes v-memo invalidate every time (negating the perf win), while still comparing only by reference for nested objects. Worth confirming the forwarded attrs in the Combobox path are identity-stable, otherwise the bounded-render assertion may pass only because the test's forwarded attrs happen to be stable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/Popper/PopperContent.vue` around lines 411 - 419, The issue
is that spreading Object.values($attrs) in the v-memo dependencies can cause
unnecessary invalidations due to identity-unstable attributes like event
handlers or new objects. To fix this in the PopperContent component, investigate
and ensure the forwarded $attrs (especially in the Combobox usage path) are
identity-stable before including them in v-memo. Alternatively, avoid spreading
all $attrs values directly if they are not stable, or selectively include only
stable attributes to maintain memoization effectiveness.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/Combobox/ComboboxContentImpl.vue`:
- Around line 131-133: The memoization for the popper branch only depends on
rootContext.filterSearch.value and rootContext.filterState.value, so other
reactive changes (e.g., rootContext.allItems growth, item disabled state
changes, rootContext.ignoreFilter toggles) won't invalidate the memoized
subtree; update the memo-dependencies expression in ComboboxContentImpl.vue (the
:memo-dependencies binding when position === 'popper') to include those
additional reactive refs (e.g., rootContext.allItems, rootContext.ignoreFilter
and any reactive map/refs tracking item disabled state) or alternatively disable
memoization for the popper branch when these other reactives are in use so slot
rendering stays correct.

---

Outside diff comments:
In `@packages/core/src/Combobox/Combobox.test.ts`:
- Around line 583-587: The current ResizeObserver mock in Combobox.test.ts is a
no-op and prevents `@floating-ui/vue`'s autoUpdate (wired via whileElementsMounted
in PopperContent.vue) from running resize-driven update cycles; replace the mock
with one that stores the callback passed to its constructor and invokes it (or
schedules repeated invocations) when observe is called so autoUpdate's update
loop runs multiple times and triggers the placement recalculations; ensure the
mock implements observe/unobserve/disconnect and calls the stored callback with
a entries array (or at least invokes it) to simulate size changes so the
memoization behavior is actually exercised.

In `@packages/core/src/Combobox/ComboboxItem.vue`:
- Around line 83-101: The v-memo dependency array in ComboboxItem.vue is missing
rootContext.filterSearch.value which can cause custom slot content that reads
rootContext.filterSearch to become stale; update the v-memo deps (the array used
on the ListboxItem element) to include rootContext.filterSearch.value so slot
render updates when the search term changes, keeping the existing deps
(isRender, rootContext.disabled.value, disabled, props.value, props.as,
props.asChild, ...Object.values($attrs)) intact.

In `@packages/core/src/Popper/PopperContent.vue`:
- Around line 409-432: The unconditional v-memo on the Primitive inside
PopperContent caches slot vnodes and causes stale slot renders for all
consumers; restrict the memoization to only the Combobox popper case by removing
or gating the v-memo in PopperContent and moving/applying it only in the
Combobox-specific implementation (e.g., ComboboxContentImpl or ComboboxContent
when position="popper"), or at minimum only enable v-memo when
props.memoDependencies is present/non-empty; update the Primitive usage in
PopperContent (and add the v-memo to ComboboxContentImpl) so other consumers
render live slot content.

---

Nitpick comments:
In `@packages/core/src/Combobox/Combobox.test.ts`:
- Line 600: The test uses loose thresholds (toBeLessThanOrEqual) for render
counts; replace those with exact assertions after confirming the real expected
counts with current memoization. Locate the assertions referencing
renderCountAfterOpen (and the analogous assertion at the second occurrence) in
Combobox.test.ts and change toBeLessThanOrEqual(...) to toBe(expectedNumber)
using the actual measured render count (e.g., toBe(2) or toBe(3)) so the test
asserts an exact render count rather than a range.

In `@packages/core/src/Popper/PopperContent.vue`:
- Around line 411-419: The issue is that spreading Object.values($attrs) in the
v-memo dependencies can cause unnecessary invalidations due to identity-unstable
attributes like event handlers or new objects. To fix this in the PopperContent
component, investigate and ensure the forwarded $attrs (especially in the
Combobox usage path) are identity-stable before including them in v-memo.
Alternatively, avoid spreading all $attrs values directly if they are not
stable, or selectively include only stable attributes to maintain memoization
effectiveness.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: add44b9e-9fb8-4301-abaa-de2ce8a4a4d7

📥 Commits

Reviewing files that changed from the base of the PR and between 843e8e7 and 6aeacad.

📒 Files selected for processing (4)
  • packages/core/src/Combobox/Combobox.test.ts
  • packages/core/src/Combobox/ComboboxContentImpl.vue
  • packages/core/src/Combobox/ComboboxItem.vue
  • packages/core/src/Popper/PopperContent.vue

Comment thread packages/core/src/Combobox/ComboboxContentImpl.vue
@yan-ad
Copy link
Copy Markdown
Contributor Author

yan-ad commented Jun 3, 2026

Addressed, thank you! I moved the memoization deps so filtering while the popper is open updates the rendered options, included dynamic attr values in the memo dependencies, and added coverage for filtering while open. I also updated the ResizeObserver mock so the test exercises the auto-update path more closely.

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.

[Bug]: When using position 'popper' on Combobox, many rerenders of PopperContent happen on open, causing noticable lag.

2 participants