Skip to content

Commit 0e80c37

Browse files
authored
fix(Select): clean up delayed presence update (#2638)
1 parent e17b2fe commit 0e80c37

3 files changed

Lines changed: 93 additions & 4 deletions

File tree

packages/core/src/Select/Select.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
22
import { fireEvent } from '@testing-library/vue'
33
import { mount } from '@vue/test-utils'
4-
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
55
import { axe } from 'vitest-axe'
66
import { nextTick } from 'vue'
77
import { handleSubmit } from '@/test'
8+
import SelectUnmountCleanup from './__test__/SelectUnmountCleanup.vue'
89
import Select from './story/_SelectTest.vue'
910

1011
beforeAll(() => {
@@ -251,6 +252,35 @@ describe('given Select with object type', async () => {
251252
})
252253
})
253254

255+
describe('given SelectContent cleanup', () => {
256+
beforeEach(() => {
257+
document.body.innerHTML = ''
258+
vi.useFakeTimers()
259+
})
260+
261+
afterEach(() => {
262+
vi.useRealTimers()
263+
})
264+
265+
it('should clear delayed presence updates when unmounted after closing', async () => {
266+
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
267+
const wrapper = mount(SelectUnmountCleanup, { attachTo: document.body })
268+
269+
await nextTick()
270+
await wrapper.find('button').trigger('click')
271+
await nextTick()
272+
273+
const timerCountAfterClose = vi.getTimerCount()
274+
expect(timerCountAfterClose).toBeGreaterThan(0)
275+
276+
await wrapper.findAll('button')[1].trigger('click')
277+
await nextTick()
278+
279+
expect(clearTimeoutSpy).toHaveBeenCalled()
280+
expect(vi.getTimerCount()).toBeLessThan(timerCountAfterClose)
281+
})
282+
})
283+
254284
describe('given Select in a form', async () => {
255285
const wrapper = mount({
256286
props: ['handleSubmit'],

packages/core/src/Select/SelectContent.vue

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
SelectContentImplEmits,
44
SelectContentImplProps,
55
} from './SelectContentImpl.vue'
6-
import { computed, onMounted, ref, watch } from 'vue'
6+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
77
88
export type SelectContentEmits = SelectContentImplEmits
99
@@ -44,15 +44,31 @@ const presenceRef = ref<InstanceType<typeof Presence>>()
4444
const present = computed(() => props.forceMount || rootContext.open.value)
4545
const renderPresence = ref(present.value)
4646
47-
watch(present, () => {
47+
let renderPresenceTimeout: ReturnType<typeof setTimeout> | undefined
48+
49+
function clearRenderPresenceTimeout() {
50+
if (renderPresenceTimeout) {
51+
clearTimeout(renderPresenceTimeout)
52+
renderPresenceTimeout = undefined
53+
}
54+
}
55+
56+
watch(present, (_value, _oldValue, onCleanup) => {
4857
// Toggle render presence after a delay (nextTick is not enough)
4958
// to allow children to re-render with the latest state.
5059
// Otherwise, they would remain in the old state during the transition,
5160
// which would prevent the animation that depend on state (e.g., data-[state=closed])
5261
// from being applied accurately.
5362
// @see https://github.com/unovue/reka-ui/issues/1865
54-
setTimeout(() => renderPresence.value = present.value)
63+
clearRenderPresenceTimeout()
64+
renderPresenceTimeout = setTimeout(() => {
65+
renderPresence.value = present.value
66+
renderPresenceTimeout = undefined
67+
})
68+
onCleanup(clearRenderPresenceTimeout)
5569
})
70+
71+
onUnmounted(clearRenderPresenceTimeout)
5672
</script>
5773

5874
<template>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
import { ref } from 'vue'
3+
import {
4+
SelectContent,
5+
SelectItem,
6+
SelectItemText,
7+
SelectPortal,
8+
SelectRoot,
9+
SelectTrigger,
10+
SelectValue,
11+
SelectViewport,
12+
} from '..'
13+
14+
const open = ref(true)
15+
const mounted = ref(true)
16+
</script>
17+
18+
<template>
19+
<button @click="open = false">
20+
Close
21+
</button>
22+
<button @click="mounted = false">
23+
Unmount
24+
</button>
25+
26+
<SelectRoot
27+
v-if="mounted"
28+
v-model:open="open"
29+
>
30+
<SelectTrigger aria-label="Fruit">
31+
<SelectValue placeholder="Please select a fruit" />
32+
</SelectTrigger>
33+
<SelectPortal>
34+
<SelectContent position="popper">
35+
<SelectViewport>
36+
<SelectItem value="apple">
37+
<SelectItemText>Apple</SelectItemText>
38+
</SelectItem>
39+
</SelectViewport>
40+
</SelectContent>
41+
</SelectPortal>
42+
</SelectRoot>
43+
</template>

0 commit comments

Comments
 (0)