Skip to content

Commit 4223cf0

Browse files
zernoniaclaude
andauthored
fix(shared): disconnect ResizeObserver on unmount in useSize (#2693)
Hoist the ResizeObserver out of the onMounted callback and disconnect it in onUnmounted, fixing a leak where observers accumulated on every mount/unmount cycle of floating components (Select, Tooltip, Popover, etc.). Adds useSize.test.ts with three tests covering cleanup, initial-size, and null-element paths. Also replaces innerHTML with textContent in the Splitter cursor-style helper. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ee61b7a commit 4223cf0

3 files changed

Lines changed: 100 additions & 5 deletions

File tree

packages/core/src/Splitter/utils/style.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function setGlobalCursorStyle(
8585
document.head.appendChild(styleElement)
8686
}
8787

88-
styleElement.innerHTML = `*{cursor: ${style}!important;}`
88+
styleElement.textContent = `*{cursor: ${style}!important;}`
8989
}
9090

9191
// the % of the group's overall space this panel should occupy.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { mount } from '@vue/test-utils'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { defineComponent, h, ref } from 'vue'
4+
import { useSize } from './useSize'
5+
6+
describe('useSize', () => {
7+
let observeMock: ReturnType<typeof vi.fn>
8+
let disconnectMock: ReturnType<typeof vi.fn>
9+
let MockResizeObserver: ReturnType<typeof vi.fn>
10+
11+
beforeEach(() => {
12+
observeMock = vi.fn()
13+
disconnectMock = vi.fn()
14+
const _observeMock = observeMock
15+
const _disconnectMock = disconnectMock
16+
// Must be a real class so `new ResizeObserver(...)` works
17+
MockResizeObserver = vi.fn(function (this: ResizeObserver) {
18+
this.observe = _observeMock
19+
this.unobserve = vi.fn()
20+
this.disconnect = _disconnectMock
21+
}) as unknown as ReturnType<typeof vi.fn>
22+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
23+
})
24+
25+
it('disconnects the observer on unmount', async () => {
26+
const TestComponent = defineComponent({
27+
setup() {
28+
const elRef = ref<HTMLElement | null>(null)
29+
const { width, height } = useSize(elRef)
30+
return { elRef, width, height }
31+
},
32+
render() {
33+
return h('div', { ref: 'elRef' })
34+
},
35+
})
36+
37+
const wrapper = mount(TestComponent, { attachTo: document.body })
38+
39+
expect(observeMock).toHaveBeenCalledTimes(1)
40+
expect(disconnectMock).toHaveBeenCalledTimes(0)
41+
42+
wrapper.unmount()
43+
44+
expect(disconnectMock).toHaveBeenCalledTimes(1)
45+
})
46+
47+
it('sets initial size from offsetWidth/offsetHeight on mount', async () => {
48+
const TestComponent = defineComponent({
49+
setup() {
50+
const elRef = ref<HTMLElement | null>(null)
51+
const { width, height } = useSize(elRef)
52+
return { elRef, width, height }
53+
},
54+
render() {
55+
return h('div', { ref: 'elRef' })
56+
},
57+
})
58+
59+
const wrapper = mount(TestComponent, { attachTo: document.body })
60+
61+
// jsdom returns 0 for offsetWidth/offsetHeight — assert they are numbers
62+
expect(typeof wrapper.vm.width).toBe('number')
63+
expect(typeof wrapper.vm.height).toBe('number')
64+
expect(wrapper.vm.width).toBe(0)
65+
expect(wrapper.vm.height).toBe(0)
66+
67+
wrapper.unmount()
68+
})
69+
70+
it('does not create an observer when element is null', async () => {
71+
const TestComponent = defineComponent({
72+
setup() {
73+
const elRef = ref<HTMLElement | null>(null)
74+
const { width, height } = useSize(elRef)
75+
return { elRef, width, height }
76+
},
77+
// render with no element bound to elRef — it stays null
78+
render() {
79+
return h('span')
80+
},
81+
})
82+
83+
const wrapper = mount(TestComponent, { attachTo: document.body })
84+
85+
expect(observeMock).not.toHaveBeenCalled()
86+
87+
// unmount should not throw even though no observer was created
88+
expect(() => wrapper.unmount()).not.toThrow()
89+
})
90+
})

packages/core/src/shared/useSize.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import type { MaybeElementRef } from '@vueuse/core'
22
import { unrefElement } from '@vueuse/core'
3-
import { computed, onMounted, ref } from 'vue'
3+
import { computed, onMounted, onUnmounted, ref } from 'vue'
44

55
export function useSize(element: MaybeElementRef) {
66
const size = ref<{ width: number, height: number }>()
77
const width = computed(() => size.value?.width ?? 0)
88
const height = computed(() => size.value?.height ?? 0)
99

10+
let resizeObserver: ResizeObserver | undefined
11+
1012
onMounted(() => {
1113
const el = unrefElement(element) as HTMLElement
1214
if (el) {
1315
// provide size as early as possible
1416
size.value = { width: el.offsetWidth, height: el.offsetHeight }
1517

16-
const resizeObserver = new ResizeObserver((entries) => {
18+
resizeObserver = new ResizeObserver((entries) => {
1719
if (!Array.isArray(entries))
1820
return
1921

@@ -47,8 +49,6 @@ export function useSize(element: MaybeElementRef) {
4749
})
4850

4951
resizeObserver.observe(el, { box: 'border-box' })
50-
51-
return () => resizeObserver.unobserve(el)
5252
}
5353
else {
5454
// We only want to reset to `undefined` when the element becomes `null`,
@@ -57,6 +57,11 @@ export function useSize(element: MaybeElementRef) {
5757
}
5858
})
5959

60+
onUnmounted(() => {
61+
resizeObserver?.disconnect()
62+
resizeObserver = undefined
63+
})
64+
6065
return {
6166
width,
6267
height,

0 commit comments

Comments
 (0)