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:
parent
8497ddeed4
commit
8c830438d5
3 changed files with 48 additions and 2 deletions
|
|
@ -1,5 +1,16 @@
|
|||
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({
|
||||
name: 'Slot',
|
||||
|
|
@ -10,7 +21,7 @@ export default defineComponent({
|
|||
if (children.length !== 1) {
|
||||
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>))
|
||||
}
|
||||
},
|
||||
|
|
|
|||
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal file
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal 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>
|
||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||
import { h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { Primitive } from '../src/lib/primitive'
|
||||
import SlotPassthrough from './fixtures/SlotPassthrough.vue'
|
||||
|
||||
describe('Primitive', () => {
|
||||
it('renders the "as" tag with forwarded attrs when asChild is false', () => {
|
||||
|
|
@ -42,4 +43,27 @@ describe('Primitive', () => {
|
|||
}),
|
||||
).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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue