fix: unwrap Fragment vnodes in Slot before cloning

<slot /> used as a passthrough outlet inside an SFC template (e.g.
DialogTrigger.vue's <Primitive as-child><slot /></Primitive>) resolves
through Vue's renderSlot() helper, which always wraps the forwarded
content in a Fragment vnode -- even when there's exactly one real
child. Slot was cloning that inert Fragment wrapper instead of the
real child, so attrs merged onto asChild-forwarded elements (data-slot,
aria-*, the click handler) silently vanished. Found via manual
Storybook verification: the Dialog trigger rendered but never opened
the dialog on click.

Unwrap single-child Fragments before merging/cloning, and add a
regression test through a real SFC fixture (a raw h()-based test
doesn't reproduce this, since it never goes through renderSlot()).
This commit is contained in:
Matthew McPeak 2026-07-13 18:37:50 -04:00
parent 8497ddeed4
commit 8c830438d5
3 changed files with 48 additions and 2 deletions

View file

@ -1,5 +1,16 @@
import type { VNode } from 'vue' import type { VNode } from 'vue'
import { cloneVNode, defineComponent, mergeProps } from 'vue' import { Fragment, cloneVNode, defineComponent, mergeProps } from 'vue'
// `<slot />` used as a passthrough outlet resolves through Vue's `renderSlot()`
// helper, which always wraps the forwarded content in a Fragment vnode (for
// diffing), even when there's exactly one real child inside. Unwrap it so we
// clone the actual element/component vnode instead of the inert wrapper.
function unwrapFragment(vnode: VNode): VNode {
if (vnode.type === Fragment && Array.isArray(vnode.children) && vnode.children.length === 1) {
return unwrapFragment(vnode.children[0] as VNode)
}
return vnode
}
export default defineComponent({ export default defineComponent({
name: 'Slot', name: 'Slot',
@ -10,7 +21,7 @@ export default defineComponent({
if (children.length !== 1) { if (children.length !== 1) {
throw new Error('Slot requires exactly one child element') throw new Error('Slot requires exactly one child element')
} }
const child = children[0] as VNode const child = unwrapFragment(children[0] as VNode)
return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record<string, unknown>)) return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record<string, unknown>))
} }
}, },

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import { Primitive } from '@/lib/primitive'
defineProps<{ asChild?: boolean }>()
</script>
<template>
<Primitive as="button" :as-child="asChild" type="button" data-slot="passthrough-wrapper">
<slot />
</Primitive>
</template>

View file

@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { h } from 'vue' import { h } from 'vue'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { Primitive } from '../src/lib/primitive' import { Primitive } from '../src/lib/primitive'
import SlotPassthrough from './fixtures/SlotPassthrough.vue'
describe('Primitive', () => { describe('Primitive', () => {
it('renders the "as" tag with forwarded attrs when asChild is false', () => { it('renders the "as" tag with forwarded attrs when asChild is false', () => {
@ -42,4 +43,27 @@ describe('Primitive', () => {
}), }),
).toThrow() ).toThrow()
}) })
it('merges attrs through a real SFC that forwards `<slot />` as asChild content', async () => {
// Regression test: `<slot />` used as a passthrough outlet inside an SFC
// template resolves through Vue's renderSlot() helper, which wraps the
// forwarded content in a Fragment vnode even when there's exactly one
// real child. Slot must unwrap that Fragment before cloning, or the
// merged attrs silently vanish onto the inert wrapper instead of the
// real child element.
let clicked = false
const wrapper = mount(SlotPassthrough, {
props: { asChild: true },
attrs: { onClick: () => { clicked = true } },
slots: {
default: () => h('a', { href: '/somewhere' }, 'Click'),
},
})
const el = wrapper.get('a')
expect(el.attributes('data-slot')).toBe('passthrough-wrapper')
expect(el.attributes('type')).toBe('button')
expect(el.attributes('href')).toBe('/somewhere')
await el.trigger('click')
expect(clicked).toBe(true)
})
}) })