]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(custom-element): support configurable app instance in defineCustomElement
authorEvan You <evan@vuejs.org>
Wed, 7 Aug 2024 08:07:47 +0000 (16:07 +0800)
committerEvan You <evan@vuejs.org>
Wed, 7 Aug 2024 08:08:06 +0000 (16:08 +0800)
Support configuring via `configureApp` option:

```js
defineCustomElement({
  // ...
}, {
  configureApp(app) {
    // ...
  }
})
```

close #4356
close #4635

packages/runtime-core/src/apiCreateApp.ts
packages/runtime-dom/__tests__/customElement.spec.ts
packages/runtime-dom/src/apiCustomElement.ts
packages/runtime-dom/src/index.ts

index 44bb0390c9942c7ba9c425da16f45a5c031319f4..7c016e12fcfc29fdd692490bc6ba6a681e6ad435 100644 (file)
@@ -50,8 +50,18 @@ export interface App<HostElement = any> {
   directive<T = any, V = any>(name: string, directive: Directive<T, V>): this
   mount(
     rootContainer: HostElement | string,
+    /**
+     * @internal
+     */
     isHydrate?: boolean,
+    /**
+     * @internal
+     */
     namespace?: boolean | ElementNamespace,
+    /**
+     * @internal
+     */
+    vnode?: VNode,
   ): ComponentPublicInstance
   unmount(): void
   onUnmount(cb: () => void): void
@@ -76,6 +86,11 @@ export interface App<HostElement = any> {
   _context: AppContext
   _instance: ComponentInternalInstance | null
 
+  /**
+   * @internal custom element vnode
+   */
+  _ceVNode?: VNode
+
   /**
    * v2 compat only
    */
@@ -337,7 +352,7 @@ export function createAppAPI<HostElement>(
                 ` you need to unmount the previous app by calling \`app.unmount()\` first.`,
             )
           }
-          const vnode = createVNode(rootComponent, rootProps)
+          const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
           // store app context on the root VNode.
           // this will be set on the root instance on initial mount.
           vnode.appContext = context
index ffa7d28f8526c5808474b27b0844c3f2339e1406..c4dc0f4e034f37acb73e02b08ded756920bf6cdc 100644 (file)
@@ -1136,4 +1136,26 @@ describe('defineCustomElement', () => {
     expect(fooVal).toBe('foo')
     expect(barVal).toBe('bar')
   })
+
+  describe('configureApp', () => {
+    test('should work', () => {
+      const E = defineCustomElement(
+        () => {
+          const msg = inject('msg')
+          return () => h('div', msg!)
+        },
+        {
+          configureApp(app) {
+            app.provide('msg', 'app-injected')
+          },
+        },
+      )
+      customElements.define('my-element-with-app', E)
+
+      container.innerHTML = `<my-element-with-app></my-element-with-app>`
+      const e = container.childNodes[0] as VueElement
+
+      expect(e.shadowRoot?.innerHTML).toBe('<div>app-injected</div>')
+    })
+  })
 })
index 306d2edf698ec790850bc9deabb2277b6c30db8d..39336303019c44f34cda0f05ce5b741d160a9dec 100644 (file)
@@ -1,4 +1,5 @@
 import {
+  type App,
   type Component,
   type ComponentCustomElementInterface,
   type ComponentInjectOptions,
@@ -10,6 +11,7 @@ import {
   type ComponentProvideOptions,
   type ComputedOptions,
   type ConcreteComponent,
+  type CreateAppFunction,
   type CreateComponentPublicInstanceWithMixins,
   type DefineComponent,
   type Directive,
@@ -18,7 +20,6 @@ import {
   type ExtractPropTypes,
   type MethodOptions,
   type RenderFunction,
-  type RootHydrateFunction,
   type SetupContext,
   type SlotsType,
   type VNode,
@@ -39,7 +40,7 @@ import {
   isPlainObject,
   toNumber,
 } from '@vue/shared'
-import { hydrate, render } from '.'
+import { createApp, createSSRApp, render } from '.'
 
 export type VueElementConstructor<P = {}> = {
   new (initialProps?: Record<string, any>): VueElement & P
@@ -49,6 +50,7 @@ export interface CustomElementOptions {
   styles?: string[]
   shadowRoot?: boolean
   nonce?: string
+  configureApp?: (app: App) => void
 }
 
 // defineCustomElement provides the same type inference as defineComponent
@@ -165,14 +167,14 @@ export function defineCustomElement(
   /**
    * @internal
    */
-  hydrate?: RootHydrateFunction,
+  _createApp?: CreateAppFunction<Element>,
 ): VueElementConstructor {
   const Comp = defineComponent(options, extraOptions) as any
   if (isPlainObject(Comp)) extend(Comp, extraOptions)
   class VueCustomElement extends VueElement {
     static def = Comp
     constructor(initialProps?: Record<string, any>) {
-      super(Comp, initialProps, hydrate)
+      super(Comp, initialProps, _createApp)
     }
   }
 
@@ -185,7 +187,7 @@ export const defineSSRCustomElement = ((
   extraOptions?: ComponentOptions,
 ) => {
   // @ts-expect-error
-  return defineCustomElement(options, extraOptions, hydrate)
+  return defineCustomElement(options, extraOptions, createSSRApp)
 }) as typeof defineCustomElement
 
 const BaseClass = (
@@ -202,6 +204,14 @@ export class VueElement
    * @internal
    */
   _instance: ComponentInternalInstance | null = null
+  /**
+   * @internal
+   */
+  _app: App | null = null
+  /**
+   * @internal
+   */
+  _nonce = this._def.nonce
 
   private _connected = false
   private _resolved = false
@@ -225,15 +235,19 @@ export class VueElement
   private _slots?: Record<string, Node[]>
 
   constructor(
+    /**
+     * Component def - note this may be an AsyncWrapper, and this._def will
+     * be overwritten by the inner component when resolved.
+     */
     private _def: InnerComponentDef,
     private _props: Record<string, any> = {},
-    hydrate?: RootHydrateFunction,
+    private _createApp: CreateAppFunction<Element> = createApp,
   ) {
     super()
-    // TODO handle non-shadowRoot hydration
-    if (this.shadowRoot && hydrate) {
-      hydrate(this._createVNode(), this.shadowRoot)
+    if (this.shadowRoot && _createApp !== createApp) {
       this._root = this.shadowRoot
+      // TODO hydration needs to be reworked
+      this._mount(_def)
     } else {
       if (__DEV__ && this.shadowRoot) {
         warn(
@@ -303,9 +317,10 @@ export class VueElement
           this._ob.disconnect()
           this._ob = null
         }
-        render(null, this._root)
+        // unmount
+        this._app && this._app.unmount()
         this._instance!.ce = undefined
-        this._instance = null
+        this._app = this._instance = null
       }
     })
   }
@@ -371,11 +386,8 @@ export class VueElement
         )
       }
 
-      // initial render
-      this._update()
-
-      // apply expose
-      this._applyExpose()
+      // initial mount
+      this._mount(def)
     }
 
     const asyncDef = (this._def as ComponentOptions).__asyncLoader
@@ -388,6 +400,34 @@ export class VueElement
     }
   }
 
+  private _mount(def: InnerComponentDef) {
+    if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) {
+      // @ts-expect-error
+      def.name = 'VueElement'
+    }
+    this._app = this._createApp(def)
+    if (def.configureApp) {
+      def.configureApp(this._app)
+    }
+    this._app._ceVNode = this._createVNode()
+    this._app.mount(this._root)
+
+    // apply expose after mount
+    const exposed = this._instance && this._instance.exposed
+    if (!exposed) return
+    for (const key in exposed) {
+      if (!hasOwn(this, key)) {
+        // exposed properties are readonly
+        Object.defineProperty(this, key, {
+          // unwrap ref to be consistent with public instance behavior
+          get: () => unref(exposed[key]),
+        })
+      } else if (__DEV__) {
+        warn(`Exposed property "${key}" already exists on custom element.`)
+      }
+    }
+  }
+
   private _resolveProps(def: InnerComponentDef) {
     const { props } = def
     const declaredPropKeys = isArray(props) ? props : Object.keys(props || {})
@@ -412,22 +452,6 @@ export class VueElement
     }
   }
 
-  private _applyExpose() {
-    const exposed = this._instance && this._instance.exposed
-    if (!exposed) return
-    for (const key in exposed) {
-      if (!hasOwn(this, key)) {
-        // exposed properties are readonly
-        Object.defineProperty(this, key, {
-          // unwrap ref to be consistent with public instance behavior
-          get: () => unref(exposed[key]),
-        })
-      } else if (__DEV__) {
-        warn(`Exposed property "${key}" already exists on custom element.`)
-      }
-    }
-  }
-
   protected _setAttr(key: string) {
     if (key.startsWith('data-v-')) return
     let value = this.hasAttribute(key) ? this.getAttribute(key) : undefined
@@ -534,7 +558,7 @@ export class VueElement
       }
       this._styleChildren.add(owner)
     }
-    const nonce = this._def.nonce
+    const nonce = this._nonce
     for (let i = styles.length - 1; i >= 0; i--) {
       const s = document.createElement('style')
       if (nonce) s.setAttribute('nonce', nonce)
index e55767b7654f1f5efcc92de4a02732c4f814caaa..706401ddd89c13b4751d53238e9543007c9d4b7d 100644 (file)
@@ -108,9 +108,9 @@ export const createApp = ((...args) => {
       // rendered by the server, the template should not contain any user data.
       component.template = container.innerHTML
       // 2.x compat check
-      if (__COMPAT__ && __DEV__) {
-        for (let i = 0; i < container.attributes.length; i++) {
-          const attr = container.attributes[i]
+      if (__COMPAT__ && __DEV__ && container.nodeType === 1) {
+        for (let i = 0; i < (container as Element).attributes.length; i++) {
+          const attr = (container as Element).attributes[i]
           if (attr.name !== 'v-cloak' && /^(v-|:|@)/.test(attr.name)) {
             compatUtils.warnDeprecation(
               DeprecationTypes.GLOBAL_MOUNT_CONTAINER,
@@ -123,7 +123,9 @@ export const createApp = ((...args) => {
     }
 
     // clear content before mounting
-    container.textContent = ''
+    if (container.nodeType === 1) {
+      container.textContent = ''
+    }
     const proxy = mount(container, false, resolveRootNamespace(container))
     if (container instanceof Element) {
       container.removeAttribute('v-cloak')
@@ -154,7 +156,9 @@ export const createSSRApp = ((...args) => {
   return app
 }) as CreateAppFunction<Element>
 
-function resolveRootNamespace(container: Element): ElementNamespace {
+function resolveRootNamespace(
+  container: Element | ShadowRoot,
+): ElementNamespace {
   if (container instanceof SVGElement) {
     return 'svg'
   }
@@ -215,7 +219,7 @@ function injectCompilerOptionsCheck(app: App) {
 
 function normalizeContainer(
   container: Element | ShadowRoot | string,
-): Element | null {
+): Element | ShadowRoot | null {
   if (isString(container)) {
     const res = document.querySelector(container)
     if (__DEV__ && !res) {