From c421fb91b2bec047e665f8269e231bf89f9bfc93 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 6 Aug 2021 19:15:55 -0400 Subject: [PATCH] feat(runtime-dom): support async component in defineCustomElement close #4261 --- packages/runtime-core/src/component.ts | 2 +- packages/runtime-core/src/hmr.ts | 10 +- .../__tests__/customElement.spec.ts | 93 ++++++++++++++ packages/runtime-dom/src/apiCustomElement.ts | 119 +++++++++++------- 4 files changed, 177 insertions(+), 47 deletions(-) diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 808ad344056..74b3eb7f5d7 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -293,7 +293,7 @@ export interface ComponentInternalInstance { /** * custom element specific HMR method */ - ceReload?: () => void + ceReload?: (newStyles?: string[]) => void // the rest are only for stateful components --------------------------------- diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index a7ccbe9c2dc..eb6ab8080bb 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/packages/runtime-core/src/hmr.ts @@ -136,13 +136,21 @@ function reload(id: string, newComp: ComponentOptions | ClassComponent) { if (instance.ceReload) { // custom element hmrDirtyComponents.add(component) - instance.ceReload() + instance.ceReload((newComp as any).styles) hmrDirtyComponents.delete(component) } else if (instance.parent) { // 4. Force the parent instance to re-render. This will cause all updated // components to be unmounted and re-mounted. Queue the update so that we // don't end up forcing the same parent to re-render multiple times. queueJob(instance.parent.update) + // instance is the inner component of an async custom element + // invoke to reset styles + if ( + (instance.parent.type as ComponentOptions).__asyncLoader && + instance.parent.ceReload + ) { + instance.parent.ceReload((newComp as any).styles) + } } else if (instance.appContext.reload) { // root instance mounted via createApp() has a reload method instance.appContext.reload() diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index e2e444073f0..042ac68a7af 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1,4 +1,5 @@ import { + defineAsyncComponent, defineCustomElement, h, inject, @@ -300,4 +301,96 @@ describe('defineCustomElement', () => { expect(style.textContent).toBe(`div { color: red; }`) }) }) + + describe('async', () => { + test('should work', async () => { + const loaderSpy = jest.fn() + const E = defineCustomElement( + defineAsyncComponent(() => { + loaderSpy() + return Promise.resolve({ + props: ['msg'], + styles: [`div { color: red }`], + render(this: any) { + return h('div', null, this.msg) + } + }) + }) + ) + customElements.define('my-el-async', E) + container.innerHTML = + `` + + `` + + await new Promise(r => setTimeout(r)) + + // loader should be called only once + expect(loaderSpy).toHaveBeenCalledTimes(1) + + const e1 = container.childNodes[0] as VueElement + const e2 = container.childNodes[1] as VueElement + + // should inject styles + expect(e1.shadowRoot!.innerHTML).toBe( + `
hello
` + ) + expect(e2.shadowRoot!.innerHTML).toBe( + `
world
` + ) + + // attr + e1.setAttribute('msg', 'attr') + await nextTick() + expect((e1 as any).msg).toBe('attr') + expect(e1.shadowRoot!.innerHTML).toBe( + `
attr
` + ) + + // props + expect(`msg` in e1).toBe(true) + ;(e1 as any).msg = 'prop' + expect(e1.getAttribute('msg')).toBe('prop') + expect(e1.shadowRoot!.innerHTML).toBe( + `
prop
` + ) + }) + + test('set DOM property before resolve', async () => { + const E = defineCustomElement( + defineAsyncComponent(() => { + return Promise.resolve({ + props: ['msg'], + render(this: any) { + return h('div', this.msg) + } + }) + }) + ) + customElements.define('my-el-async-2', E) + + const e1 = new E() + + // set property before connect + e1.msg = 'hello' + + const e2 = new E() + + container.appendChild(e1) + container.appendChild(e2) + + // set property after connect but before resolve + e2.msg = 'world' + + await new Promise(r => setTimeout(r)) + + expect(e1.shadowRoot!.innerHTML).toBe(`
hello
`) + expect(e2.shadowRoot!.innerHTML).toBe(`
world
`) + + e1.msg = 'world' + expect(e1.shadowRoot!.innerHTML).toBe(`
world
`) + + e2.msg = 'hello' + expect(e2.shadowRoot!.innerHTML).toBe(`
hello
`) + }) + }) }) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index b054ccff3e3..ca29a436c72 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -18,6 +18,7 @@ import { defineComponent, nextTick, warn, + ConcreteComponent, ComponentOptions } from '@vue/runtime-core' import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared' @@ -124,32 +125,13 @@ export function defineCustomElement( hydate?: RootHydrateFunction ): VueElementConstructor { const Comp = defineComponent(options as any) - const { props } = options - const rawKeys = props ? (isArray(props) ? props : Object.keys(props)) : [] - const attrKeys = rawKeys.map(hyphenate) - const propKeys = rawKeys.map(camelize) - class VueCustomElement extends VueElement { static def = Comp - static get observedAttributes() { - return attrKeys - } constructor(initialProps?: Record) { - super(Comp, initialProps, attrKeys, propKeys, hydate) + super(Comp, initialProps, hydate) } } - for (const key of propKeys) { - Object.defineProperty(VueCustomElement.prototype, key, { - get() { - return this._getProp(key) - }, - set(val) { - this._setProp(key, val) - } - }) - } - return VueCustomElement } @@ -162,6 +144,8 @@ const BaseClass = ( typeof HTMLElement !== 'undefined' ? HTMLElement : class {} ) as typeof HTMLElement +type InnerComponentDef = ConcreteComponent & { styles?: string[] } + export class VueElement extends BaseClass { /** * @internal @@ -169,13 +153,12 @@ export class VueElement extends BaseClass { _instance: ComponentInternalInstance | null = null private _connected = false + private _resolved = false private _styles?: HTMLStyleElement[] constructor( - private _def: ComponentOptions & { styles?: string[] }, + private _def: InnerComponentDef, private _props: Record = {}, - private _attrKeys: string[], - private _propKeys: string[], hydrate?: RootHydrateFunction ) { super() @@ -189,27 +172,25 @@ export class VueElement extends BaseClass { ) } this.attachShadow({ mode: 'open' }) - this._applyStyles() } - } - attributeChangedCallback(name: string, _oldValue: string, newValue: string) { - if (this._attrKeys.includes(name)) { - this._setProp(camelize(name), toNumber(newValue), false) + // set initial attrs + for (let i = 0; i < this.attributes.length; i++) { + this._setAttr(this.attributes[i].name) } + // watch future attr changes + const observer = new MutationObserver(mutations => { + for (const m of mutations) { + this._setAttr(m.attributeName!) + } + }) + observer.observe(this, { attributes: true }) } connectedCallback() { this._connected = true if (!this._instance) { - // 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) - } - } + this._resolveDef() render(this._createVNode(), this.shadowRoot!) } } @@ -224,6 +205,50 @@ export class VueElement extends BaseClass { }) } + /** + * resolve inner component definition (handle possible async component) + */ + private _resolveDef() { + if (this._resolved) { + return + } + + const resolve = (def: InnerComponentDef) => { + this._resolved = true + // check if there are props set pre-upgrade or connect + for (const key of Object.keys(this)) { + if (key[0] !== '_') { + this._setProp(key, this[key as keyof this]) + } + } + const { props, styles } = def + // defining getter/setters on prototype + const rawKeys = props ? (isArray(props) ? props : Object.keys(props)) : [] + for (const key of rawKeys.map(camelize)) { + Object.defineProperty(this, key, { + get() { + return this._getProp(key) + }, + set(val) { + this._setProp(key, val) + } + }) + } + this._applyStyles(styles) + } + + const asyncDef = (this._def as ComponentOptions).__asyncLoader + if (asyncDef) { + asyncDef().then(resolve) + } else { + resolve(this._def) + } + } + + protected _setAttr(key: string) { + this._setProp(camelize(key), toNumber(this.getAttribute(key)), false) + } + /** * @internal */ @@ -261,16 +286,20 @@ export class VueElement extends BaseClass { instance.isCE = true // HMR if (__DEV__) { - instance.ceReload = () => { - this._instance = null - // reset styles + instance.ceReload = newStyles => { + // alawys reset styles if (this._styles) { this._styles.forEach(s => this.shadowRoot!.removeChild(s)) this._styles.length = 0 } - this._applyStyles() - // reload - render(this._createVNode(), this.shadowRoot!) + this._applyStyles(newStyles) + // if this is an async component, ceReload is called from the inner + // component so no need to reload the async wrapper + if (!(this._def as ComponentOptions).__asyncLoader) { + // reload + this._instance = null + render(this._createVNode(), this.shadowRoot!) + } } } @@ -299,9 +328,9 @@ export class VueElement extends BaseClass { return vnode } - private _applyStyles() { - if (this._def.styles) { - this._def.styles.forEach(css => { + private _applyStyles(styles: string[] | undefined) { + if (styles) { + styles.forEach(css => { const s = document.createElement('style') s.textContent = css this.shadowRoot!.appendChild(s)