]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-dom): support async component in defineCustomElement
authorEvan You <yyx990803@gmail.com>
Fri, 6 Aug 2021 23:15:55 +0000 (19:15 -0400)
committerEvan You <yyx990803@gmail.com>
Fri, 6 Aug 2021 23:15:55 +0000 (19:15 -0400)
close #4261

packages/runtime-core/src/component.ts
packages/runtime-core/src/hmr.ts
packages/runtime-dom/__tests__/customElement.spec.ts
packages/runtime-dom/src/apiCustomElement.ts

index 808ad3440568d433d048108b829f6a3d45764193..74b3eb7f5d7347c6bfcd38e9dd687820b762e2d7 100644 (file)
@@ -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 ---------------------------------
 
index a7ccbe9c2dce40b53edc2c710db261cd300c69bc..eb6ab8080bbba4481a1b943eb5e6e7c0a1db861b 100644 (file)
@@ -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()
index e2e444073f085ad38f0d01a0e0755ea0dcb43734..042ac68a7afba2e5611df2c30a2367e0c2dfea84 100644 (file)
@@ -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 =
+        `<my-el-async msg="hello"></my-el-async>` +
+        `<my-el-async msg="world"></my-el-async>`
+
+      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(
+        `<div>hello</div><style>div { color: red }</style>`
+      )
+      expect(e2.shadowRoot!.innerHTML).toBe(
+        `<div>world</div><style>div { color: red }</style>`
+      )
+
+      // attr
+      e1.setAttribute('msg', 'attr')
+      await nextTick()
+      expect((e1 as any).msg).toBe('attr')
+      expect(e1.shadowRoot!.innerHTML).toBe(
+        `<div>attr</div><style>div { color: red }</style>`
+      )
+
+      // props
+      expect(`msg` in e1).toBe(true)
+      ;(e1 as any).msg = 'prop'
+      expect(e1.getAttribute('msg')).toBe('prop')
+      expect(e1.shadowRoot!.innerHTML).toBe(
+        `<div>prop</div><style>div { color: red }</style>`
+      )
+    })
+
+    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(`<div>hello</div>`)
+      expect(e2.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
+
+      e1.msg = 'world'
+      expect(e1.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
+
+      e2.msg = 'hello'
+      expect(e2.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
+    })
+  })
 })
index b054ccff3e3cad55cad6c7ce4f588b9794168a6e..ca29a436c722eaa1c8606d1fb29a2b00d480e072 100644 (file)
@@ -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<string, any>) {
-      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<string, any> = {},
-    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)