Skip to content

Commit 1724106

Browse files
Don’t unmount portal targets used by other portals (#3144)
* Refactor * Don’t unmount portal targets used by other portals * Add test * Update changelog
1 parent 722ad2d commit 1724106

File tree

3 files changed

+83
-3
lines changed

3 files changed

+83
-3
lines changed

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
### Fixed
1313

1414
- Prevent closing the `Combobox` component when clicking inside the scrollbar area ([#3104](https://github.com/tailwindlabs/headlessui/pull/3104))
15+
- Don’t unmount portal targets used by other portals ([#3144](https://github.com/tailwindlabs/headlessui/pull/3144))
1516

1617
## [1.7.20] - 2024-04-15
1718

packages/@headlessui-vue/src/components/portal/portal.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,48 @@ it('should be possible to force the Portal into a specific element using PortalG
391391
`"<div><div><div data-v-app=\\"\\"><main><aside id=\\"group-1\\">A<div data-headlessui-portal=\\"\\">Next to A</div></aside><section id=\\"group-2\\"><span>B</span></section><!--teleport start--><!--teleport end--></main></div></div></div>"`
392392
)
393393
})
394+
395+
it('the root shared by multiple portals should not unmount when they change in the same tick', async () => {
396+
let a = ref(false)
397+
let b = ref(false)
398+
399+
renderTemplate({
400+
template: html`
401+
<main>
402+
<Portal v-if="a">Portal A</Portal>
403+
<Portal v-if="b">Portal B</Portal>
404+
</main>
405+
`,
406+
setup: () => ({ a, b }),
407+
})
408+
409+
await new Promise<void>(nextTick)
410+
411+
let root = () => document.querySelector('#headlessui-portal-root')
412+
413+
// There is no portal root initially because there are no visible portals
414+
expect(root()).toBe(null)
415+
416+
// Show portal A
417+
a.value = true
418+
await new Promise<void>(nextTick)
419+
420+
// There is a portal root now because there is a visible portal
421+
expect(root()).not.toBe(null)
422+
423+
// Swap portal A for portal B
424+
a.value = false
425+
b.value = true
426+
427+
await new Promise<void>(nextTick)
428+
429+
// The portal root is still there because there are still visible portals
430+
expect(root()).not.toBe(null)
431+
432+
// Hide portal B
433+
b.value = false
434+
await new Promise<void>(nextTick)
435+
436+
// The portal root is gone because there are no visible portals
437+
expect(root()).toBe(null)
438+
})

packages/@headlessui-vue/src/components/portal/portal.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ function getPortalRoot(contextElement?: Element | null) {
4444
return ownerDocument.body.appendChild(root)
4545
}
4646

47+
// A counter that keeps track of how many portals are currently using
48+
// a specific portal root. This is used to know when we can safely
49+
// remove the portal root from the DOM.
50+
const counter = new WeakMap<HTMLElement, number>()
51+
52+
function getCount(el: HTMLElement): number {
53+
return counter.get(el) ?? 0
54+
}
55+
56+
function setCount(el: HTMLElement, cb: (val: number) => number): number {
57+
let newCount = cb(getCount(el))
58+
59+
if (newCount <= 0) {
60+
counter.delete(el)
61+
} else {
62+
counter.set(el, newCount)
63+
}
64+
65+
return newCount
66+
}
67+
4768
export let Portal = defineComponent({
4869
name: 'Portal',
4970
props: {
@@ -63,6 +84,11 @@ export let Portal = defineComponent({
6384
: groupContext.resolveTarget()
6485
)
6586

87+
// Make a note that we are using this element
88+
if (myTarget.value) {
89+
setCount(myTarget.value, (val) => val + 1)
90+
}
91+
6692
let ready = ref(false)
6793
onMounted(() => {
6894
ready.value = true
@@ -95,9 +121,17 @@ export let Portal = defineComponent({
95121
if (!root) return
96122
if (myTarget.value !== root) return
97123

98-
if (myTarget.value.children.length <= 0) {
99-
myTarget.value.parentElement?.removeChild(myTarget.value)
100-
}
124+
// We no longer need the portal target
125+
let remaining = setCount(myTarget.value, (val) => val - 1)
126+
127+
// However, if another portal is still using the same target
128+
// we should not remove it.
129+
if (remaining) return
130+
131+
// There are still children in the portal, we should not remove it.
132+
if (myTarget.value.children.length > 0) return
133+
134+
myTarget.value.parentElement?.removeChild(myTarget.value)
101135
})
102136

103137
return () => {

0 commit comments

Comments
 (0)