diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index d4f86f53cf2..6d210a3125d 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -99,13 +99,66 @@ describe('defineCustomElement', () => { container.appendChild(e) expect(e.shadowRoot!.innerHTML).toBe('
one
two
') + // 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('
three
two
') + expect(e.getAttribute('foo')).toBe('three') + + e.foo = null + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
two
') + expect(e.hasAttribute('foo')).toBe(false) e.bazQux = 'four' await nextTick() - expect(e.shadowRoot!.innerHTML).toBe('
three
four
') + expect(e.shadowRoot!.innerHTML).toBe('
four
') + 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 = `` + 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`) }) }) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 82992ae0e09..dc1f8ed781d 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -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

= { @@ -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,41 +222,61 @@ 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] - this._props[key] = val - if (this._instance && val !== oldValue) { - this._instance.props[key] = val + /** + * @internal + */ + protected _setProp(key: string, val: any, shouldReflect = true) { + if (val !== this._props[key]) { + this._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 { - const vnode = createVNode(this._def, this._props) - vnode.ce = instance => { - this._instance = instance - instance.isCE = true + private _createVNode(): VNode { + const vnode = createVNode(this._def, extend({}, this._props)) + if (!this._instance) { + vnode.ce = instance => { + this._instance = instance + instance.isCE = true - // intercept emit - instance.emit = (event: string, ...args: any[]) => { - this.dispatchEvent( - new CustomEvent(event, { - detail: args - }) - ) - } + // intercept emit + instance.emit = (event: string, ...args: any[]) => { + this.dispatchEvent( + new CustomEvent(event, { + detail: args + }) + ) + } - // locate nearest Vue custom element parent for provide/inject - let parent: Node | null = this - while ( - (parent = parent && (parent.parentNode || (parent as ShadowRoot).host)) - ) { - if (parent instanceof VueElement) { - instance.parent = parent._instance - break + // locate nearest Vue custom element parent for provide/inject + let parent: Node | null = this + while ( + (parent = + parent && (parent.parentNode || (parent as ShadowRoot).host)) + ) { + if (parent instanceof VueElement) { + instance.parent = parent._instance + break + } } } }