diff --git a/packages/library/src/lib/slot.ts b/packages/library/src/lib/slot.ts
index e1b53d9..8f4768c 100644
--- a/packages/library/src/lib/slot.ts
+++ b/packages/library/src/lib/slot.ts
@@ -1,5 +1,16 @@
import type { VNode } from 'vue'
-import { cloneVNode, defineComponent, mergeProps } from 'vue'
+import { Fragment, cloneVNode, defineComponent, mergeProps } from 'vue'
+
+// `` 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))
}
},
diff --git a/packages/library/tests/fixtures/SlotPassthrough.vue b/packages/library/tests/fixtures/SlotPassthrough.vue
new file mode 100644
index 0000000..418d1d5
--- /dev/null
+++ b/packages/library/tests/fixtures/SlotPassthrough.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/packages/library/tests/primitive.spec.ts b/packages/library/tests/primitive.spec.ts
index 7658264..d3442aa 100644
--- a/packages/library/tests/primitive.spec.ts
+++ b/packages/library/tests/primitive.spec.ts
@@ -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 `` as asChild content', async () => {
+ // Regression test: `` 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)
+ })
})