diff --git a/packages/core/src/Combobox/Combobox.test.ts b/packages/core/src/Combobox/Combobox.test.ts
index a02fc7b94..874ba4be6 100644
--- a/packages/core/src/Combobox/Combobox.test.ts
+++ b/packages/core/src/Combobox/Combobox.test.ts
@@ -2,8 +2,9 @@ import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
-import { nextTick } from 'vue'
+import { defineComponent, h, nextTick, ref } from 'vue'
import { handleSubmit, sleep } from '@/test'
+import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxRoot, ComboboxTrigger, ComboboxViewport } from '.'
import Combobox from './story/_Combobox.vue'
import ComboboxObject from './story/_ComboboxObject.vue'
import ComboboxTagsInput from './story/_ComboboxTagsInput.vue'
@@ -542,3 +543,93 @@ describe('given combobox handleBlur with deferred close', () => {
externalButton.remove()
})
})
+
+describe('comboboxContent with popper positioning', () => {
+ const getSlotRenderCount = vi.fn(() => ({ value: 0 }))
+
+ const PopperCombobox = defineComponent({
+ setup() {
+ const modelValue = ref('')
+ const slotRenderCount = getSlotRenderCount()
+ const options = Array.from({ length: 60 }, (_, index) => `Option ${index}`)
+
+ return () => h(ComboboxRoot, {
+ 'modelValue': modelValue.value,
+ 'onUpdate:modelValue': (value: string) => modelValue.value = value,
+ }, {
+ default: () => [
+ h(ComboboxAnchor, null, {
+ default: () => [
+ h(ComboboxInput),
+ h(ComboboxTrigger, null, { default: () => 'Open' }),
+ ],
+ }),
+ h(ComboboxContent, { position: 'popper' }, {
+ default: () => {
+ slotRenderCount.value += 1
+ return h(ComboboxViewport, null, {
+ default: () => options.map(option => h(ComboboxItem, { key: option, value: option }, { default: () => option })),
+ })
+ },
+ }),
+ ],
+ })
+ },
+ })
+
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ getSlotRenderCount.mockClear()
+ globalThis.ResizeObserver = class ResizeObserver {
+ private callback: ResizeObserverCallback
+
+ constructor(callback: ResizeObserverCallback) {
+ this.callback = callback
+ }
+
+ observe(target: Element) {
+ this.callback([{ target } as ResizeObserverEntry], this)
+ this.callback([{ target } as ResizeObserverEntry], this)
+ }
+
+ unobserve() {}
+ disconnect() {}
+ }
+ })
+
+ it('does not rerender option slot content after popper position updates', async () => {
+ const slotRenderCount = { value: 0 }
+ getSlotRenderCount.mockReturnValue(slotRenderCount)
+
+ const wrapper = mount(PopperCombobox, { attachTo: document.body })
+
+ await wrapper.find('button').trigger('click')
+ await nextTick()
+
+ expect(slotRenderCount.value).toBe(3)
+
+ await sleep(0)
+ await nextTick()
+
+ expect(slotRenderCount.value).toBe(4)
+ })
+
+ it('updates visible options when filtering while popper content is open', async () => {
+ const slotRenderCount = { value: 0 }
+ getSlotRenderCount.mockReturnValue(slotRenderCount)
+
+ const wrapper = mount(PopperCombobox, { attachTo: document.body })
+
+ await wrapper.find('button').trigger('click')
+ await nextTick()
+
+ expect(wrapper.text()).toContain('Option 1')
+ expect(wrapper.text()).toContain('Option 59')
+
+ await wrapper.find('input').setValue('Option 59')
+ await nextTick()
+
+ expect(wrapper.text()).toContain('Option 59')
+ expect(wrapper.text()).not.toContain('Option 1')
+ })
+})
diff --git a/packages/core/src/Combobox/ComboboxContentImpl.vue b/packages/core/src/Combobox/ComboboxContentImpl.vue
index ab6f1ee47..966ad12d7 100644
--- a/packages/core/src/Combobox/ComboboxContentImpl.vue
+++ b/packages/core/src/Combobox/ComboboxContentImpl.vue
@@ -128,6 +128,9 @@ onUnmounted(() => {
v-bind="{ ...$attrs, ...forwardedProps }"
:id="rootContext.contentId"
:ref="forwardRef"
+ :memo-dependencies="position === 'popper'
+ ? [rootContext.filterSearch.value, rootContext.filterState.value]
+ : undefined"
:data-state="rootContext.open.value ? 'open' : 'closed'"
:data-empty="isEmpty ? '' : undefined"
:style="{
diff --git a/packages/core/src/Combobox/ComboboxItem.vue b/packages/core/src/Combobox/ComboboxItem.vue
index df40d116c..a39daf2bd 100644
--- a/packages/core/src/Combobox/ComboboxItem.vue
+++ b/packages/core/src/Combobox/ComboboxItem.vue
@@ -80,6 +80,7 @@ onUnmounted(() => {
v-bind="props"
:id="id"
ref="primitiveElement"
+ v-memo="[isRender, rootContext.filterSearch.value, rootContext.disabled.value, disabled, props.value, props.as, props.asChild, ...Object.values($attrs)]"
:disabled="rootContext.disabled.value || disabled"
@select="(event) => {
emits('select', event as any)
diff --git a/packages/core/src/Popper/PopperContent.vue b/packages/core/src/Popper/PopperContent.vue
index b7e7b1623..0a6cc2b03 100644
--- a/packages/core/src/Popper/PopperContent.vue
+++ b/packages/core/src/Popper/PopperContent.vue
@@ -32,6 +32,11 @@ export const PopperContentPropsDefaultValue = {
}
export interface PopperContentProps extends PrimitiveProps {
+ /**
+ * Reactive dependencies that should invalidate the memoized content subtree.
+ */
+ memoDependencies?: unknown[]
+
/**
* The preferred side of the trigger to render against when open.
* Will be reversed when collisions occur and avoidCollisions
@@ -402,10 +407,37 @@ providePopperContentContext({
}"
>
+
+
+
+