From 37d2ce5d8e0fac4a00064f02b05f91f69b2d5d5e Mon Sep 17 00:00:00 2001 From: Evan You Date: Sat, 3 Aug 2024 13:14:22 +0800 Subject: [PATCH] feat(custom-element): support shadowRoot: false in defineCustomElement() close #4314 close #4404 --- .../runtime-core/src/helpers/renderSlot.ts | 15 +- .../__tests__/customElement.spec.ts | 69 ++++++++- packages/runtime-dom/src/apiCustomElement.ts | 136 +++++++++++++----- 3 files changed, 185 insertions(+), 35 deletions(-) diff --git a/packages/runtime-core/src/helpers/renderSlot.ts b/packages/runtime-core/src/helpers/renderSlot.ts index f0b13904f08..5a7492d7015 100644 --- a/packages/runtime-core/src/helpers/renderSlot.ts +++ b/packages/runtime-core/src/helpers/renderSlot.ts @@ -10,12 +10,12 @@ import { type VNode, type VNodeArrayChildren, createBlock, + createVNode, isVNode, openBlock, } from '../vnode' import { PatchFlags, SlotFlags } from '@vue/shared' import { warn } from '../warning' -import { createVNode } from '@vue/runtime-core' import { isAsyncWrapper } from '../apiAsyncComponent' /** @@ -37,8 +37,19 @@ export function renderSlot( isAsyncWrapper(currentRenderingInstance!.parent) && currentRenderingInstance!.parent.isCE) ) { + // in custom element mode, render as actual slot outlets + // wrap it with a fragment because in shadowRoot: false mode the slot + // element gets replaced by injected content if (name !== 'default') props.name = name - return createVNode('slot', props, fallback && fallback()) + return ( + openBlock(), + createBlock( + Fragment, + null, + [createVNode('slot', props, fallback && fallback())], + PatchFlags.STABLE_FRAGMENT, + ) + ) } let slot = slots[name] diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index f503b2c0285..ab4d6f47939 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -505,7 +505,7 @@ describe('defineCustomElement', () => { }) customElements.define('my-el-slots', E) - test('default slot', () => { + test('render slots correctly', () => { container.innerHTML = `hi` const e = container.childNodes[0] as VueElement // native slots allocation does not affect innerHTML, so we just @@ -777,4 +777,71 @@ describe('defineCustomElement', () => { ) }) }) + + describe('shadowRoot: false', () => { + const E = defineCustomElement({ + shadowRoot: false, + props: { + msg: { + type: String, + default: 'hello', + }, + }, + render() { + return h('div', this.msg) + }, + }) + customElements.define('my-el-shadowroot-false', E) + + test('should work', async () => { + function raf() { + return new Promise(resolve => { + requestAnimationFrame(resolve) + }) + } + + container.innerHTML = `` + const e = container.childNodes[0] as VueElement + await raf() + expect(e).toBeInstanceOf(E) + expect(e._instance).toBeTruthy() + expect(e.innerHTML).toBe(`
hello
`) + expect(e.shadowRoot).toBe(null) + }) + + const toggle = ref(true) + const ES = defineCustomElement({ + shadowRoot: false, + render() { + return [ + renderSlot(this.$slots, 'default'), + toggle.value ? renderSlot(this.$slots, 'named') : null, + renderSlot(this.$slots, 'omitted', {}, () => [h('div', 'fallback')]), + ] + }, + }) + customElements.define('my-el-shadowroot-false-slots', ES) + + test('should render slots', async () => { + container.innerHTML = + `` + + `defaulttext` + + `
named
` + + `
` + const e = container.childNodes[0] as VueElement + // native slots allocation does not affect innerHTML, so we just + // verify that we've rendered the correct native slots here... + expect(e.innerHTML).toBe( + `defaulttext` + + `
named
` + + `
fallback
`, + ) + + toggle.value = false + await nextTick() + expect(e.innerHTML).toBe( + `defaulttext` + `` + `
fallback
`, + ) + }) + }) }) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 97a84ee918d..f87bf266418 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -21,6 +21,7 @@ import { type SetupContext, type SlotsType, type VNode, + type VNodeProps, createVNode, defineComponent, nextTick, @@ -33,21 +34,28 @@ export type VueElementConstructor

= { new (initialProps?: Record): VueElement & P } +export interface CustomElementOptions { + styles?: string[] + shadowRoot?: boolean +} + // defineCustomElement provides the same type inference as defineComponent // so most of the following overloads should be kept in sync w/ defineComponent. // overload 1: direct setup function export function defineCustomElement( setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction, - options?: Pick & { - props?: (keyof Props)[] - }, + options?: Pick & + CustomElementOptions & { + props?: (keyof Props)[] + }, ): VueElementConstructor export function defineCustomElement( setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction, - options?: Pick & { - props?: ComponentObjectPropsOptions - }, + options?: Pick & + CustomElementOptions & { + props?: ComponentObjectPropsOptions + }, ): VueElementConstructor // overload 2: defineCustomElement with options object, infer props from options @@ -81,27 +89,27 @@ export function defineCustomElement< : { [key in PropsKeys]?: any }, ResolvedProps = InferredProps & EmitsToProps, >( - options: { + options: CustomElementOptions & { props?: (RuntimePropsOptions & ThisType) | PropsKeys[] } & ComponentOptionsBase< - ResolvedProps, - SetupBindings, - Data, - Computed, - Methods, - Mixin, - Extends, - RuntimeEmitsOptions, - EmitsKeys, - {}, // Defaults - InjectOptions, - InjectKeys, - Slots, - LocalComponents, - Directives, - Exposed, - Provide - > & + ResolvedProps, + SetupBindings, + Data, + Computed, + Methods, + Mixin, + Extends, + RuntimeEmitsOptions, + EmitsKeys, + {}, // Defaults + InjectOptions, + InjectKeys, + Slots, + LocalComponents, + Directives, + Exposed, + Provide + > & ThisType< CreateComponentPublicInstanceWithMixins< Readonly, @@ -163,7 +171,7 @@ const BaseClass = ( typeof HTMLElement !== 'undefined' ? HTMLElement : class {} ) as typeof HTMLElement -type InnerComponentDef = ConcreteComponent & { styles?: string[] } +type InnerComponentDef = ConcreteComponent & CustomElementOptions export class VueElement extends BaseClass { /** @@ -176,14 +184,19 @@ export class VueElement extends BaseClass { private _numberProps: Record | null = null private _styles?: HTMLStyleElement[] private _ob?: MutationObserver | null = null + private _root: Element | ShadowRoot + private _slots?: Record + constructor( private _def: InnerComponentDef, private _props: Record = {}, hydrate?: RootHydrateFunction, ) { super() + // TODO handle non-shadowRoot hydration if (this.shadowRoot && hydrate) { hydrate(this._createVNode(), this.shadowRoot) + this._root = this.shadowRoot } else { if (__DEV__ && this.shadowRoot) { warn( @@ -191,7 +204,12 @@ export class VueElement extends BaseClass { `defined as hydratable. Use \`defineSSRCustomElement\`.`, ) } - this.attachShadow({ mode: 'open' }) + if (_def.shadowRoot !== false) { + this.attachShadow({ mode: 'open' }) + this._root = this.shadowRoot! + } else { + this._root = this + } if (!(this._def as ComponentOptions).__asyncLoader) { // for sync component defs we can immediately resolve props this._resolveProps(this._def) @@ -200,6 +218,9 @@ export class VueElement extends BaseClass { } connectedCallback() { + if (!this.shadowRoot) { + this._parseSlots() + } this._connected = true if (!this._instance) { if (this._resolved) { @@ -218,7 +239,7 @@ export class VueElement extends BaseClass { this._ob.disconnect() this._ob = null } - render(null, this.shadowRoot!) + render(null, this._root) this._instance = null } }) @@ -353,11 +374,16 @@ export class VueElement extends BaseClass { } private _update() { - render(this._createVNode(), this.shadowRoot!) + render(this._createVNode(), this._root) } private _createVNode(): VNode { - const vnode = createVNode(this._def, extend({}, this._props)) + const baseProps: VNodeProps = {} + if (!this.shadowRoot) { + baseProps.onVnodeMounted = baseProps.onVnodeUpdated = + this._renderSlots.bind(this) + } + const vnode = createVNode(this._def, extend(baseProps, this._props)) if (!this._instance) { vnode.ce = instance => { this._instance = instance @@ -367,7 +393,7 @@ export class VueElement extends BaseClass { instance.ceReload = newStyles => { // always reset styles if (this._styles) { - this._styles.forEach(s => this.shadowRoot!.removeChild(s)) + this._styles.forEach(s => this._root.removeChild(s)) this._styles.length = 0 } this._applyStyles(newStyles) @@ -416,7 +442,7 @@ export class VueElement extends BaseClass { styles.forEach(css => { const s = document.createElement('style') s.textContent = css - this.shadowRoot!.appendChild(s) + this._root.appendChild(s) // record for HMR if (__DEV__) { ;(this._styles || (this._styles = [])).push(s) @@ -424,4 +450,50 @@ export class VueElement extends BaseClass { }) } } + + /** + * Only called when shaddowRoot is false + */ + private _parseSlots() { + const slots: VueElement['_slots'] = (this._slots = {}) + let n + while ((n = this.firstChild)) { + const slotName = + (n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default' + ;(slots[slotName] || (slots[slotName] = [])).push(n) + this.removeChild(n) + } + } + + /** + * Only called when shaddowRoot is false + */ + private _renderSlots() { + const outlets = this.querySelectorAll('slot') + const scopeId = this._instance!.type.__scopeId + 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 parent = o.parentNode! + if (content) { + for (const n of content) { + // for :slotted css + if (scopeId && n.nodeType === 1) { + const id = scopeId + '-s' + const walker = document.createTreeWalker(n, 1) + ;(n as Element).setAttribute(id, '') + let child + while ((child = walker.nextNode())) { + ;(child as Element).setAttribute(id, '') + } + } + parent.insertBefore(n, o) + } + } else { + while (o.firstChild) parent.insertBefore(o.firstChild, o) + } + parent.removeChild(o) + } + } }