Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(runtime-dom): defineCustomElement without shadowDom (#4314) #4404

Closed
wants to merge 11 commits into from
Closed
36 changes: 36 additions & 0 deletions packages/runtime-dom/__tests__/customElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,42 @@ describe('defineCustomElement', () => {
})
})

describe('shadowRoot: false', () => {
const E = defineCustomElement(
{
props: {
msg: {
type: String,
default: 'hello'
}
},
render() {
return h('div', this.msg)
}
},
{
shadowRoot: false
}
)
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)
})
})

describe('props', () => {
const E = defineCustomElement({
props: ['foo', 'bar', 'bazQux'],
Expand Down
149 changes: 119 additions & 30 deletions packages/runtime-dom/src/apiCustomElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ import {
SlotsType
} from '@vue/runtime-core'
import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
import HTMLParsedElement from './html-parsed-element'
import { hydrate, render } from '.'

export type VueElementConstructor<P = {}> = {
new (initialProps?: Record<string, any>): VueElement & P
}

export interface DefineCustomElementConfig {
/**
* Render inside a shadow root DOM element
* @default true
*/
shadowRoot?: boolean
}

// defineCustomElement provides the same type inference as defineComponent
// so most of the following overloads should be kept in sync w/ defineComponent.

Expand All @@ -38,7 +47,8 @@ export function defineCustomElement<Props, RawBindings = object>(
setup: (
props: Readonly<Props>,
ctx: SetupContext
) => RawBindings | RenderFunction
) => RawBindings | RenderFunction,
config?: DefineCustomElementConfig
): VueElementConstructor<Props>

// overload 2: object format with no props
Expand Down Expand Up @@ -69,7 +79,8 @@ export function defineCustomElement<
I,
II,
S
> & { styles?: string[] }
> & { styles?: string[] },
config?: DefineCustomElementConfig
): VueElementConstructor<Props>

// overload 3: object format with array props declaration
Expand Down Expand Up @@ -100,7 +111,8 @@ export function defineCustomElement<
I,
II,
S
> & { styles?: string[] }
> & { styles?: string[] },
config?: DefineCustomElementConfig
): VueElementConstructor<{ [K in PropNames]: any }>

// overload 4: object format with object props declaration
Expand Down Expand Up @@ -131,39 +143,47 @@ export function defineCustomElement<
I,
II,
S
> & { styles?: string[] }
> & { styles?: string[] },
config?: DefineCustomElementConfig
): VueElementConstructor<ExtractPropTypes<PropsOptions>>

// overload 5: defining a custom element from the returned value of
// `defineComponent`
export function defineCustomElement(options: {
new (...args: any[]): ComponentPublicInstance
}): VueElementConstructor
export function defineCustomElement(
options: {
new (...args: any[]): ComponentPublicInstance
},
config?: DefineCustomElementConfig
): VueElementConstructor

/*! #__NO_SIDE_EFFECTS__ */
export function defineCustomElement(
options: any,
config?: DefineCustomElementConfig,
hydrate?: RootHydrateFunction
): VueElementConstructor {
const Comp = defineComponent(options) as any
class VueCustomElement extends VueElement {
static def = Comp
constructor(initialProps?: Record<string, any>) {
super(Comp, initialProps, hydrate)
super(Comp, initialProps, config, hydrate)
}
}

return VueCustomElement
}

/*! #__NO_SIDE_EFFECTS__ */
export const defineSSRCustomElement = ((options: any) => {
// @ts-ignore
return defineCustomElement(options, hydrate)
export const defineSSRCustomElement = ((
options: any,
config?: DefineCustomElementConfig
) => {
// @ts-expect-error
gnuletik marked this conversation as resolved.
Show resolved Hide resolved
return defineCustomElement(options, config, hydrate)
}) as typeof defineCustomElement

const BaseClass = (
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
typeof HTMLElement !== 'undefined' ? HTMLParsedElement : class {}
) as typeof HTMLElement

type InnerComponentDef = ConcreteComponent & { styles?: string[] }
Expand All @@ -178,31 +198,69 @@ export class VueElement extends BaseClass {
private _resolved = false
private _numberProps: Record<string, true> | null = null
private _styles?: HTMLStyleElement[]
private _slots?: Element[]
private _ob?: MutationObserver | null = null

constructor(
private _def: InnerComponentDef,
private _props: Record<string, any> = {},
private _config: DefineCustomElementConfig = {},
hydrate?: RootHydrateFunction
) {
super()
if (this.shadowRoot && hydrate) {
hydrate(this._createVNode(), 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._config = extend(
{
shadowRoot: true
},
this._config
)

if (this._config.shadowRoot) {
if (this.shadowRoot && hydrate) {
hydrate(this._createVNode(), this._root!)
} 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 (!(this._def as ComponentOptions).__asyncLoader) {
// for sync component defs we can immediately resolve props
this._resolveProps(this._def)
}
}
this.attachShadow({ mode: 'open' })
if (!(this._def as ComponentOptions).__asyncLoader) {
// for sync component defs we can immediately resolve props
this._resolveProps(this._def)
} else {
if (hydrate) {
hydrate(this._createVNode(), this._root!)
}
}
}

get _root(): Element | ShadowRoot | null {
return this._config.shadowRoot ? this.shadowRoot : this
}

connectedCallback() {
if (this._config.shadowRoot) {
this._connect()
} else {
// @ts-expect-error
super.connectedCallback()
}
}

// use of parsedCallback when shadowRoot is disabled
// to wait for slots to be parsed
// see https://stackoverflow.com/a/52884370
parsedCallback() {
if (!this._config.shadowRoot) {
this._connect()
}
}

_connect() {
this._connected = true
if (!this._instance) {
if (this._resolved) {
Expand All @@ -221,7 +279,7 @@ export class VueElement extends BaseClass {
}
nextTick(() => {
if (!this._connected) {
render(null, this.shadowRoot!)
render(null, this._root!)
this._instance = null
}
})
Expand Down Expand Up @@ -273,6 +331,14 @@ export class VueElement extends BaseClass {
this._resolveProps(def)
}

// replace slot
if (!this._config.shadowRoot) {
this._slots = Array.from(this.children).map(
n => n.cloneNode(true) as Element
)
this.replaceChildren()
}

// apply CSS
this._applyStyles(styles)

Expand Down Expand Up @@ -356,21 +422,44 @@ 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))
let childs = null
// web components without shadow DOM do not supports native slot
// so, we create a VNode based on the content of child nodes.
// NB: named slots are currently not supported
if (!this._config.shadowRoot) {
childs = () => {
const toObj = (a: NamedNodeMap) => {
const res: Record<string, string | null> = {}
for (let i = 0, l = a.length; i < l; i++) {
const attr = a[i]
res[attr.nodeName] = attr.nodeValue
}
return res
}
return this._slots!.map(ele => {
const attrs = ele.attributes ? toObj(ele.attributes) : {}
attrs.innerHTML = ele.innerHTML
return createVNode(ele.tagName, attrs, null)
})
}
}
const vnode = createVNode(this._def, extend({}, this._props), childs)
if (!this._instance) {
vnode.ce = instance => {
this._instance = instance
instance.isCE = true
if (this._config.shadowRoot) {
instance.isCE = true
}
// HMR
if (__DEV__) {
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 @@ -419,7 +508,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)
Expand Down
Loading
Loading