From 444ea89e42b456449651b387543027dab12b413e Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Mon, 28 Jul 2025 19:43:31 -0700 Subject: [PATCH] feat(runtime-dom): `useNativeSlots` helper --- .../__tests__/customElement.spec.ts | 107 ++++++++++++++++++ packages/runtime-dom/src/apiCustomElement.ts | 47 +++++++- packages/runtime-dom/src/index.ts | 1 + 3 files changed, 149 insertions(+), 6 deletions(-) diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index c44840df5e3..91444915fc3 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -17,6 +17,7 @@ import { render, renderSlot, useHost, + useNativeSlots, useShadowRoot, } from '../src' @@ -1399,6 +1400,112 @@ describe('defineCustomElement', () => { const style = el.shadowRoot?.querySelector('style')! expect(style.textContent).toBe(`div { color: red; }`) }) + + describe('useNativeSlots', () => { + test('default slot', async () => { + async function testDefaultSlot(shadowRoot: boolean) { + let slots: Record + const Comp = defineCustomElement( + { + setup() { + slots = useNativeSlots()! + }, + render() { + return h('div', null, [ + renderSlot(this.$slots, 'default', undefined, () => [ + 'fallback', + ]), + ]) + }, + }, + { shadowRoot }, + ) + + const ceTag = + 'my-el-use-native-slots' + (shadowRoot ? '-sr' : '-no-sr') + customElements.define(ceTag, Comp) + + container.innerHTML = `<${ceTag}>
Content
` + await nextTick() + expect(Object.keys(slots!)).toHaveLength(1) + expect(slots!['default']).toMatchObject([ + { tagName: 'DIV', textContent: 'Content' }, + ]) + + container.innerHTML = `<${ceTag}>
New Content
` + await nextTick() + expect(Object.keys(slots!)).toHaveLength(1) + expect(slots!['default']).toMatchObject([ + { tagName: 'DIV', textContent: 'New Content' }, + ]) + + container.innerHTML = `<${ceTag}>
New Content
More Content` + await nextTick() + expect(Object.keys(slots!)).toHaveLength(1) + expect(slots!['default']).toMatchObject([ + { tagName: 'DIV', textContent: 'New Content' }, + { tagName: 'SPAN', textContent: 'More Content' }, + ]) + + container.innerHTML = `<${ceTag}>` + await nextTick() + expect(Object.keys(slots!)).toHaveLength(0) + } + + await testDefaultSlot(true) + await testDefaultSlot(false) + }) + + test('named slots', async () => { + async function testNamedSlots(shadowRoot: boolean) { + let slots: Record + const Comp = defineCustomElement( + { + setup() { + slots = useNativeSlots()! + }, + render() { + return h('div', null, [ + renderSlot(this.$slots, 'slot1', undefined, () => [ + 'fallback', + ]), + renderSlot(this.$slots, 'slot2', undefined, () => [ + 'fallback', + ]), + ]) + }, + }, + { shadowRoot }, + ) + + const ceTag = + 'my-el-use-native-slots-named' + (shadowRoot ? '-sr' : '-no-sr') + customElements.define(ceTag, Comp) + + container.innerHTML = `<${ceTag}>
Content
` + await nextTick() + expect(Object.keys(slots!)).toHaveLength(1) + expect(slots!['slot1']).toMatchObject([ + { tagName: 'DIV', textContent: 'Content' }, + ]) + + container.innerHTML = `<${ceTag}>
Content
` + await nextTick() + expect(Object.keys(slots!)).toHaveLength(1) + expect(slots!['slot2']).toMatchObject([ + { tagName: 'DIV', textContent: 'Content' }, + ]) + + container.innerHTML = `<${ceTag}>
New Content
More Content

Even More

` + await nextTick() + // The slot values provided were not defined as outlets by component + expect(Object.keys(slots!)).toHaveLength(0) + } + + await testNamedSlots(true) + await testNamedSlots(false) + }) + }) }) describe('expose', () => { diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index edf7c431353..61201697220 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -241,7 +241,10 @@ export class VueElement */ private _childStyles?: Map private _ob?: MutationObserver | null = null - private _slots?: Record + _slots: Record = {} + // differs from `_slots` by only exposing slot values that actually have corresponding + // slot outlets defined by this component + _componentDefinedSlots: Record = {} constructor( /** @@ -275,9 +278,13 @@ export class VueElement // avoid resolving component if it's not connected if (!this.isConnected) return - // avoid re-parsing slots if already resolved - if (!this.shadowRoot && !this._resolved) { - this._parseSlots() + if (this.shadowRoot) { + this._root.addEventListener('slotchange', this._slotChangeEventListener) + } else { + // avoid re-parsing slots if already resolved + if (!this._resolved) { + this._parseSlots() + } } this._connected = true @@ -326,6 +333,18 @@ export class VueElement } } + _slotChangeEventListener = (event: Event): void => { + if (event.target instanceof HTMLSlotElement) { + const slotName = event.target.name || 'default' + const assignedNodes = event.target.assignedNodes() + if (!assignedNodes.length) { + delete this._componentDefinedSlots[slotName] + } else { + this._componentDefinedSlots[slotName] = assignedNodes + } + } + } + disconnectedCallback(): void { this._connected = false nextTick(() => { @@ -334,6 +353,12 @@ export class VueElement this._ob.disconnect() this._ob = null } + if (this.shadowRoot) { + this._root.removeEventListener( + 'slotchange', + this._slotChangeEventListener, + ) + } // unmount this._app && this._app.unmount() if (this._instance) this._instance.ce = undefined @@ -621,7 +646,7 @@ export class VueElement * Only called when shadowRoot is false */ private _parseSlots() { - const slots: VueElement['_slots'] = (this._slots = {}) + const slots: VueElement['_slots'] = this._slots let n while ((n = this.firstChild)) { const slotName = @@ -640,9 +665,10 @@ export class VueElement for (let i = 0; i < outlets.length; i++) { const o = outlets[i] as HTMLSlotElement const slotName = o.getAttribute('name') || 'default' - const content = this._slots![slotName] + const content = this._slots[slotName] const parent = o.parentNode! if (content) { + this._componentDefinedSlots[slotName] = content for (const n of content) { // for :slotted css if (scopeId && n.nodeType === 1) { @@ -716,3 +742,12 @@ export function useShadowRoot(): ShadowRoot | null { const el = __DEV__ ? useHost('useShadowRoot') : useHost() return el && el.shadowRoot } + +/** + * Retrieve the nodes assigned to each `` of the current custom element. + * Only usable in setup() of a `defineCustomElement` component. + */ +export function useNativeSlots(): Record | null { + const el = __DEV__ ? useHost('useNativeSlots') : useHost() + return el && el._componentDefinedSlots +} diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts index b241458dba7..40e6f41b781 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -260,6 +260,7 @@ export { defineCustomElement, defineSSRCustomElement, useShadowRoot, + useNativeSlots, useHost, VueElement, type VueElementConstructor,