feat: custom element reflection, casting and edge cases

This commit is contained in:
Evan You 2021-07-13 12:23:51 -04:00
parent bf4893c17c
commit 00f0b3c465
2 changed files with 116 additions and 34 deletions

View File

@ -99,13 +99,66 @@ describe('defineCustomElement', () => {
container.appendChild(e)
expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')
// reflect
// should reflect primitive value
expect(e.getAttribute('foo')).toBe('one')
// should not reflect rich data
expect(e.hasAttribute('bar')).toBe(false)
e.foo = 'three'
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>two</div>')
expect(e.getAttribute('foo')).toBe('three')
e.foo = null
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>two</div>')
expect(e.hasAttribute('foo')).toBe(false)
e.bazQux = 'four'
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>four</div>')
expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>four</div>')
expect(e.getAttribute('baz-qux')).toBe('four')
})
test('attribute -> prop type casting', async () => {
const E = defineCustomElement({
props: {
foo: Number,
bar: Boolean
},
render() {
return [this.foo, typeof this.foo, this.bar, typeof this.bar].join(
' '
)
}
})
customElements.define('my-el-props-cast', E)
container.innerHTML = `<my-el-props-cast foo="1"></my-el-props-cast>`
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot!.innerHTML).toBe(`1 number false boolean`)
e.setAttribute('bar', '')
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean`)
e.setAttribute('foo', '2e1')
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean`)
})
test('handling properties set before upgrading', () => {
const E = defineCustomElement({
props: ['foo'],
render() {
return `foo: ${this.foo}`
}
})
const el = document.createElement('my-el-upgrade') as any
el.foo = 'hello'
container.appendChild(el)
customElements.define('my-el-upgrade', E)
expect(el.shadowRoot.innerHTML).toBe(`foo: hello`)
})
})

View File

@ -20,7 +20,7 @@ import {
nextTick,
warn
} from '@vue/runtime-core'
import { camelize, hyphenate, isArray } from '@vue/shared'
import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
import { hydrate, render } from '.'
type VueElementConstructor<P = {}> = {
@ -134,7 +134,7 @@ export function defineCustomElement(
return attrKeys
}
constructor() {
super(Comp, attrKeys, hydate)
super(Comp, attrKeys, propKeys, hydate)
}
}
@ -173,12 +173,13 @@ export class VueElement extends HTMLElement {
constructor(
private _def: Component,
private _attrs: string[],
private _attrKeys: string[],
private _propKeys: string[],
hydrate?: RootHydrateFunction
) {
super()
if (this.shadowRoot && hydrate) {
hydrate(this._initVNode(), this.shadowRoot)
hydrate(this._createVNode(), this.shadowRoot)
} else {
if (__DEV__ && this.shadowRoot) {
warn(
@ -191,15 +192,23 @@ export class VueElement extends HTMLElement {
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
if (this._attrs.includes(name)) {
this._setProp(camelize(name), newValue)
if (this._attrKeys.includes(name)) {
this._setProp(camelize(name), toNumber(newValue), false)
}
}
connectedCallback() {
this._connected = true
if (!this._instance) {
render(this._initVNode(), this.shadowRoot!)
// check if there are props set pre-upgrade
for (const key of this._propKeys) {
if (this.hasOwnProperty(key)) {
const value = (this as any)[key]
delete (this as any)[key]
this._setProp(key, value)
}
}
render(this._createVNode(), this.shadowRoot!)
}
}
@ -213,20 +222,38 @@ export class VueElement extends HTMLElement {
})
}
/**
* @internal
*/
protected _getProp(key: string) {
return this._props[key]
}
protected _setProp(key: string, val: any) {
const oldValue = this._props[key]
/**
* @internal
*/
protected _setProp(key: string, val: any, shouldReflect = true) {
if (val !== this._props[key]) {
this._props[key] = val
if (this._instance && val !== oldValue) {
this._instance.props[key] = val
if (this._instance) {
render(this._createVNode(), this.shadowRoot!)
}
// reflect
if (shouldReflect) {
if (val === true) {
this.setAttribute(hyphenate(key), '')
} else if (typeof val === 'string' || typeof val === 'number') {
this.setAttribute(hyphenate(key), val + '')
} else if (!val) {
this.removeAttribute(hyphenate(key))
}
}
}
}
protected _initVNode(): VNode<any, any> {
const vnode = createVNode(this._def, this._props)
private _createVNode(): VNode<any, any> {
const vnode = createVNode(this._def, extend({}, this._props))
if (!this._instance) {
vnode.ce = instance => {
this._instance = instance
instance.isCE = true
@ -243,7 +270,8 @@ export class VueElement extends HTMLElement {
// locate nearest Vue custom element parent for provide/inject
let parent: Node | null = this
while (
(parent = parent && (parent.parentNode || (parent as ShadowRoot).host))
(parent =
parent && (parent.parentNode || (parent as ShadowRoot).host))
) {
if (parent instanceof VueElement) {
instance.parent = parent._instance
@ -251,6 +279,7 @@ export class VueElement extends HTMLElement {
}
}
}
}
return vnode
}
}