Skip to content

Commit

Permalink
feat(custom-element): support shadowRoot: false in defineCustomElement()
Browse files Browse the repository at this point in the history
close #4314
close #4404
  • Loading branch information
yyx990803 committed Aug 3, 2024
1 parent 267093c commit 37d2ce5
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 35 deletions.
15 changes: 13 additions & 2 deletions packages/runtime-core/src/helpers/renderSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -37,8 +37,19 @@ export function renderSlot(
isAsyncWrapper(currentRenderingInstance!.parent) &&
currentRenderingInstance!.parent.isCE)
) {
// in custom element mode, render <slot/> 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]
Expand Down
69 changes: 68 additions & 1 deletion packages/runtime-dom/__tests__/customElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ describe('defineCustomElement', () => {
})
customElements.define('my-el-slots', E)

test('default slot', () => {
test('render slots correctly', () => {
container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
const e = container.childNodes[0] as VueElement
// native slots allocation does not affect innerHTML, so we just
Expand Down Expand Up @@ -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 = `<my-el-shadowroot-false></my-el-shadowroot-false>`
const e = container.childNodes[0] as VueElement
await raf()
expect(e).toBeInstanceOf(E)
expect(e._instance).toBeTruthy()
expect(e.innerHTML).toBe(`<div>hello</div>`)
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 =
`<my-el-shadowroot-false-slots>` +
`<span>default</span>text` +
`<div slot="named">named</div>` +
`</my-el-shadowroot-false-slots>`
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(
`<span>default</span>text` +
`<div slot="named">named</div>` +
`<div>fallback</div>`,
)

toggle.value = false
await nextTick()
expect(e.innerHTML).toBe(
`<span>default</span>text` + `<!---->` + `<div>fallback</div>`,
)
})
})
})
136 changes: 104 additions & 32 deletions packages/runtime-dom/src/apiCustomElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type SetupContext,
type SlotsType,
type VNode,
type VNodeProps,
createVNode,
defineComponent,
nextTick,
Expand All @@ -33,21 +34,28 @@ export type VueElementConstructor<P = {}> = {
new (initialProps?: Record<string, any>): 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<Props, RawBindings = object>(
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
props?: (keyof Props)[]
},
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
CustomElementOptions & {
props?: (keyof Props)[]
},
): VueElementConstructor<Props>
export function defineCustomElement<Props, RawBindings = object>(
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
props?: ComponentObjectPropsOptions<Props>
},
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
CustomElementOptions & {
props?: ComponentObjectPropsOptions<Props>
},
): VueElementConstructor<Props>

// overload 2: defineCustomElement with options object, infer props from options
Expand Down Expand Up @@ -81,27 +89,27 @@ export function defineCustomElement<
: { [key in PropsKeys]?: any },
ResolvedProps = InferredProps & EmitsToProps<RuntimeEmitsOptions>,
>(
options: {
options: CustomElementOptions & {
props?: (RuntimePropsOptions & ThisType<void>) | 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<ResolvedProps>,
Expand Down Expand Up @@ -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 {
/**
Expand All @@ -176,22 +184,32 @@ export class VueElement extends BaseClass {
private _numberProps: Record<string, true> | null = null
private _styles?: HTMLStyleElement[]
private _ob?: MutationObserver | null = null
private _root: Element | ShadowRoot
private _slots?: Record<string, Node[]>

constructor(
private _def: InnerComponentDef,
private _props: Record<string, any> = {},
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(
`Custom element has pre-rendered declarative shadow root but is not ` +
`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)
Expand All @@ -200,6 +218,9 @@ export class VueElement extends BaseClass {
}

connectedCallback() {
if (!this.shadowRoot) {
this._parseSlots()
}
this._connected = true
if (!this._instance) {
if (this._resolved) {
Expand All @@ -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
}
})
Expand Down Expand Up @@ -353,11 +374,16 @@ export class VueElement extends BaseClass {
}

private _update() {
render(this._createVNode(), this.shadowRoot!)
render(this._createVNode(), this._root)
}

private _createVNode(): VNode<any, any> {
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
Expand All @@ -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)
Expand Down Expand Up @@ -416,12 +442,58 @@ 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)
}
})
}
}

/**
* 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)
}
}
}

0 comments on commit 37d2ce5

Please sign in to comment.