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) + }) })