]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat: implement defineVaporCustomElement (#14017)
authoredison <daiwei521@126.com>
Mon, 10 Nov 2025 03:49:15 +0000 (11:49 +0800)
committerGitHub <noreply@github.com>
Mon, 10 Nov 2025 03:49:15 +0000 (11:49 +0800)
24 files changed:
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts
packages/compiler-vapor/src/generators/component.ts
packages/compiler-vapor/src/ir/index.ts
packages/compiler-vapor/src/transforms/transformElement.ts
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/componentCurrentInstance.ts
packages/runtime-core/src/hmr.ts
packages/runtime-core/src/index.ts
packages/runtime-dom/src/apiCustomElement.ts
packages/runtime-dom/src/index.ts
packages/runtime-vapor/__tests__/customElement.spec.ts [new file with mode: 0644]
packages/runtime-vapor/src/apiCreateApp.ts
packages/runtime-vapor/src/apiDefineAsyncComponent.ts
packages/runtime-vapor/src/apiDefineVaporCustomElement.ts [new file with mode: 0644]
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/componentProps.ts
packages/runtime-vapor/src/componentSlots.ts
packages/runtime-vapor/src/components/Teleport.ts
packages/runtime-vapor/src/dom/prop.ts
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/renderEffect.ts
packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts [new file with mode: 0644]
packages/vue/__tests__/e2e/ssr-custom-element.spec.ts

index 0aca6514e1ccebcc09d5498a111a4e96118dd288..1befe5482f801e792a39c734835943b18111119f 100644 (file)
@@ -333,6 +333,15 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > custom element 1`] = `
+"import { createPlainElement as _createPlainElement } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createPlainElement("my-custom-element", null, null, true)
+  return n0
+}"
+`;
+
 exports[`compiler: element transform > dynamic component > capitalized version w/ static binding 1`] = `
 "import { resolveDynamicComponent as _resolveDynamicComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue';
 
index a4a833726cb075f2bbbff799ac9c13d73c73ae16..3847567cf371dd4f3332daf85b5bbe910f4720b7 100644 (file)
@@ -1042,6 +1042,17 @@ describe('compiler: element transform', () => {
     expect(code).contain('return null')
   })
 
+  test('custom element', () => {
+    const { code } = compileWithElementTransform(
+      '<my-custom-element></my-custom-element>',
+      {
+        isCustomElement: tag => tag === 'my-custom-element',
+      },
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('createPlainElement')
+  })
+
   test('svg', () => {
     const t = `<svg><circle r="40"></circle></svg>`
     const { code, ir } = compileWithElementTransform(t)
index d4bfeefdb61cb320cc374f385bf03e10100e72de..c2da7a3f22e657a74f1cafc333abdf09e079fdcf 100644 (file)
@@ -73,9 +73,11 @@ export function genCreateComponent(
     ...genCall(
       operation.dynamic && !operation.dynamic.isStatic
         ? helper('createDynamicComponent')
-        : operation.asset
-          ? helper('createComponentWithFallback')
-          : helper('createComponent'),
+        : operation.isCustomElement
+          ? helper('createPlainElement')
+          : operation.asset
+            ? helper('createComponentWithFallback')
+            : helper('createComponent'),
       tag,
       rawProps,
       rawSlots,
@@ -86,7 +88,9 @@ export function genCreateComponent(
   ]
 
   function genTag() {
-    if (operation.dynamic) {
+    if (operation.isCustomElement) {
+      return JSON.stringify(operation.tag)
+    } else if (operation.dynamic) {
       if (operation.dynamic.isStatic) {
         return genCall(
           helper('resolveDynamicComponent'),
index 177997d68b178d80088bcaad8f3ad9e8319bbeae..9a4415e410386250ca660aae59a40769c72685d9 100644 (file)
@@ -204,6 +204,7 @@ export interface CreateComponentIRNode extends BaseIRNode {
   root: boolean
   once: boolean
   dynamic?: SimpleExpressionNode
+  isCustomElement: boolean
   parent?: number
   anchor?: number
   append?: boolean
index ee1c522abd7c3f3dd2bceaf35d05c132d77abca6..db3943f2ad83487d011ab1420edd3352c0d23501 100644 (file)
@@ -58,7 +58,12 @@ export const transformElement: NodeTransform = (node, context) => {
     )
       return
 
-    const isComponent = node.tagType === ElementTypes.COMPONENT
+    // treat custom elements as components because the template helper cannot
+    // resolve them properly; they require creation via createElement
+    const isCustomElement = !!context.options.isCustomElement(node.tag)
+    const isComponent =
+      node.tagType === ElementTypes.COMPONENT || isCustomElement
+
     const isDynamicComponent = isComponentTag(node.tag)
     const propsResult = buildProps(
       node,
@@ -78,9 +83,10 @@ export const transformElement: NodeTransform = (node, context) => {
       parent = parent.parent
     }
     const singleRoot =
-      context.root === parent &&
-      parent.node.children.filter(child => child.type !== NodeTypes.COMMENT)
-        .length === 1
+      (context.root === parent &&
+        parent.node.children.filter(child => child.type !== NodeTypes.COMMENT)
+          .length === 1) ||
+      isCustomElement
 
     if (isComponent) {
       transformComponentElement(
@@ -89,6 +95,7 @@ export const transformElement: NodeTransform = (node, context) => {
         singleRoot,
         context,
         isDynamicComponent,
+        isCustomElement,
       )
     } else {
       transformNativeElement(
@@ -108,6 +115,7 @@ function transformComponentElement(
   singleRoot: boolean,
   context: TransformContext,
   isDynamicComponent: boolean,
+  isCustomElement: boolean,
 ) {
   const dynamicComponent = isDynamicComponent
     ? resolveDynamicComponent(node)
@@ -116,7 +124,7 @@ function transformComponentElement(
   let { tag } = node
   let asset = true
 
-  if (!dynamicComponent) {
+  if (!dynamicComponent && !isCustomElement) {
     const fromSetup = resolveSetupReference(tag, context)
     if (fromSetup) {
       tag = fromSetup
@@ -161,6 +169,7 @@ function transformComponentElement(
     slots: [...context.slots],
     once: context.inVOnce,
     dynamic: dynamicComponent,
+    isCustomElement,
   }
   context.slots = []
 }
index f651d76d79922d09bcc7fe1b73b77f8bf90db2a0..e3a63bf846d486d361b9283a835604261fb3e18e 100644 (file)
@@ -111,6 +111,11 @@ export interface App<HostElement = any> {
    */
   _ceVNode?: VNode
 
+  /**
+   * @internal vapor custom element instance
+   */
+  _ceComponent?: GenericComponentInstance | null
+
   /**
    * v2 compat only
    */
index 7ac52a2e997182fc0a4ffb5d19698e314e2f3a8b..727958fd84ce797e98049a97dbc3314a0d40b9ea 100644 (file)
@@ -5,6 +5,7 @@ import type {
 } from './component'
 import { currentRenderingInstance } from './componentRenderContext'
 import { type EffectScope, setCurrentScope } from '@vue/reactivity'
+import { warn } from './warning'
 
 /**
  * @internal
@@ -90,3 +91,36 @@ export const setCurrentInstance = (
     simpleSetCurrentInstance(instance)
   }
 }
+
+const internalOptions = ['ce'] as const
+
+/**
+ * @internal
+ */
+export const useInstanceOption = <K extends (typeof internalOptions)[number]>(
+  key: K,
+  silent = false,
+): {
+  hasInstance: boolean
+  value: GenericComponentInstance[K] | undefined
+} => {
+  const instance = getCurrentGenericInstance()
+  if (!instance) {
+    if (__DEV__ && !silent) {
+      warn(`useInstanceOption called without an active component instance.`)
+    }
+    return { hasInstance: false, value: undefined }
+  }
+
+  if (!internalOptions.includes(key)) {
+    if (__DEV__) {
+      warn(
+        `useInstanceOption only accepts ` +
+          ` ${internalOptions.map(k => `'${k}'`).join(', ')} as key, got '${key}'.`,
+      )
+    }
+    return { hasInstance: true, value: undefined }
+  }
+
+  return { hasInstance: true, value: instance[key] }
+}
index c8029950e2d5f027362b36ff8ef91afc6858a1f5..8a0ed8746e27e7afcb4a48bf618ae09ba71d18db 100644 (file)
@@ -123,7 +123,16 @@ function reload(id: string, newComp: HMRComponent): void {
   // create a snapshot which avoids the set being mutated during updates
   const instances = [...record.instances]
 
-  if (newComp.__vapor) {
+  if (newComp.__vapor && !instances.some(i => i.ceReload)) {
+    // For multiple instances with the same __hmrId, remove styles first before reload
+    // to avoid the second instance's style removal deleting the first instance's
+    // newly added styles (since hmrReload is synchronous)
+    for (const instance of instances) {
+      // update custom element child style
+      if (instance.root && instance.root.ce && instance !== instance.root) {
+        instance.root.ce._removeChildStyle(instance.type)
+      }
+    }
     for (const instance of instances) {
       instance.hmrReload!(newComp)
     }
index a05f8985e1ad335e9fded0c5885ab368dd0fafe5..67ff4f0e913fc5b5770d20e0909708bc8fa4830c 100644 (file)
@@ -105,6 +105,11 @@ export {
 // plugins
 export { getCurrentInstance } from './component'
 
+/**
+ * @internal
+ */
+export { useInstanceOption } from './component'
+
 // For raw render function users
 export { h } from './h'
 // Advanced render function utilities
index 85d37bc117e06f337dff6d6451e5b81b7479d558..596e03112bad3045625e7d9b49cf60b11929643d 100644 (file)
@@ -3,7 +3,6 @@ import {
   type Component,
   type ComponentCustomElementInterface,
   type ComponentInjectOptions,
-  type ComponentInternalInstance,
   type ComponentObjectPropsOptions,
   type ComponentOptions,
   type ComponentOptionsBase,
@@ -19,6 +18,7 @@ import {
   type EmitsOptions,
   type EmitsToProps,
   type ExtractPropTypes,
+  type GenericComponentInstance,
   type MethodOptions,
   type RenderFunction,
   type SetupContext,
@@ -27,9 +27,9 @@ import {
   type VNodeProps,
   createVNode,
   defineComponent,
-  getCurrentInstance,
   nextTick,
   unref,
+  useInstanceOption,
   warn,
 } from '@vue/runtime-core'
 import {
@@ -200,7 +200,11 @@ const BaseClass = (
 
 type InnerComponentDef = ConcreteComponent & CustomElementOptions
 
-export class VueElement
+export abstract class VueElementBase<
+    E = Element,
+    C = Component,
+    Def extends CustomElementOptions & { props?: any } = InnerComponentDef,
+  >
   extends BaseClass
   implements ComponentCustomElementInterface
 {
@@ -208,7 +212,7 @@ export class VueElement
   /**
    * @internal
    */
-  _instance: ComponentInternalInstance | null = null
+  _instance: GenericComponentInstance | null = null
   /**
    * @internal
    */
@@ -220,54 +224,68 @@ export class VueElement
   /**
    * @internal
    */
-  _nonce: string | undefined = this._def.nonce
-
+  _nonce: string | undefined
   /**
    * @internal
    */
   _teleportTargets?: Set<Element>
 
-  private _connected = false
-  private _resolved = false
-  private _patching = false
-  private _dirty = false
-  private _numberProps: Record<string, true> | null = null
-  private _styleChildren = new WeakSet()
-  private _pendingResolve: Promise<void> | undefined
-  private _parent: VueElement | undefined
+  protected _connected = false
+  protected _resolved = false
+  protected _numberProps: Record<string, true> | null = null
+  protected _styleChildren: WeakSet<object> = new WeakSet()
+  protected _pendingResolve: Promise<void> | undefined
+  protected _parent: VueElementBase | undefined
+  protected _patching = false
+  protected _dirty = false
+
+  protected _def: Def
+  protected _props: Record<string, any>
+  protected _createApp: CreateAppFunction<E, C>
+
   /**
    * dev only
    */
-  private _styles?: HTMLStyleElement[]
+  protected _styles?: HTMLStyleElement[]
   /**
    * dev only
    */
-  private _childStyles?: Map<string, HTMLStyleElement[]>
-  private _ob?: MutationObserver | null = null
-  private _slots?: Record<string, Node[]>
+  protected _childStyles?: Map<string, HTMLStyleElement[]>
+  protected _ob?: MutationObserver | null = null
+  protected _slots?: Record<string, Node[]>
+
+  /**
+   * Check if this custom element needs hydration.
+   * Returns true if it has a pre-rendered declarative shadow root that
+   * needs to be hydrated.
+   */
+  protected abstract _needsHydration(): boolean
+  protected abstract _mount(def: Def): void
+  protected abstract _update(): void
+  protected abstract _unmount(): void
+  protected abstract _updateSlotNodes(slot: Map<Node, Node[]>): void
 
   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> = {},
-    private _createApp: CreateAppFunction<Element> = createApp,
+    def: Def,
+    props: Record<string, any> | undefined = {},
+    createAppFn: CreateAppFunction<E, C>,
   ) {
     super()
-    if (this.shadowRoot && _createApp !== createApp) {
-      this._root = this.shadowRoot
+    this._def = def
+    this._props = props
+    this._createApp = createAppFn
+    this._nonce = def.nonce
+
+    if (this._needsHydration()) {
+      this._root = this.shadowRoot!
     } else {
-      if (__DEV__ && this.shadowRoot) {
-        warn(
-          `Custom element has pre-rendered declarative shadow root but is not ` +
-            `defined as hydratable. Use \`defineSSRCustomElement\`.`,
-        )
-      }
-      if (_def.shadowRoot !== false) {
+      if (def.shadowRoot !== false) {
         this.attachShadow(
-          extend({}, _def.shadowRootOptions, {
+          extend({}, def.shadowRootOptions, {
             mode: 'open',
           }) as ShadowRootInit,
         )
@@ -293,7 +311,7 @@ export class VueElement
     while (
       (parent = parent && (parent.parentNode || (parent as ShadowRoot).host))
     ) {
-      if (parent instanceof VueElement) {
+      if (parent instanceof VueElementBase) {
         this._parent = parent
         break
       }
@@ -301,7 +319,7 @@ export class VueElement
 
     if (!this._instance) {
       if (this._resolved) {
-        this._mount(this._def)
+        this._mountComponent(this._def)
       } else {
         if (parent && parent._pendingResolve) {
           this._pendingResolve = parent._pendingResolve.then(() => {
@@ -315,24 +333,6 @@ export class VueElement
     }
   }
 
-  private _setParent(parent = this._parent) {
-    if (parent) {
-      this._instance!.parent = parent._instance
-      this._inheritParentContext(parent)
-    }
-  }
-
-  private _inheritParentContext(parent = this._parent) {
-    // #13212, the provides object of the app context must inherit the provides
-    // object from the parent element so we can inject values from both places
-    if (parent && this._app) {
-      Object.setPrototypeOf(
-        this._app._context.provides,
-        parent._instance!.provides,
-      )
-    }
-  }
-
   disconnectedCallback(): void {
     this._connected = false
     nextTick(() => {
@@ -341,10 +341,7 @@ export class VueElement
           this._ob.disconnect()
           this._ob = null
         }
-        // unmount
-        this._app && this._app.unmount()
-        if (this._instance) this._instance.ce = undefined
-        this._app = this._instance = null
+        this._unmount()
         if (this._teleportTargets) {
           this._teleportTargets.clear()
           this._teleportTargets = undefined
@@ -353,6 +350,28 @@ export class VueElement
     })
   }
 
+  protected _setParent(
+    parent: VueElementBase | undefined = this._parent,
+  ): void {
+    if (parent && this._instance) {
+      this._instance.parent = parent._instance
+      this._inheritParentContext(parent)
+    }
+  }
+
+  protected _inheritParentContext(
+    parent: VueElementBase | undefined = this._parent,
+  ): void {
+    // #13212, the provides object of the app context must inherit the provides
+    // object from the parent element so we can inject values from both places
+    if (parent && this._app) {
+      Object.setPrototypeOf(
+        this._app._context.provides,
+        parent._instance!.provides,
+      )
+    }
+  }
+
   private _processMutations(mutations: MutationRecord[]) {
     for (const m of mutations) {
       this._setAttr(m.attributeName!)
@@ -377,7 +396,7 @@ export class VueElement
 
     this._ob.observe(this, { attributes: true })
 
-    const resolve = (def: InnerComponentDef, isAsync = false) => {
+    const resolve = (def: Def) => {
       this._resolved = true
       this._pendingResolve = undefined
 
@@ -412,35 +431,29 @@ export class VueElement
       }
 
       // initial mount
-      this._mount(def)
+      this._mountComponent(def)
     }
 
     const asyncDef = (this._def as ComponentOptions).__asyncLoader
     if (asyncDef) {
-      this._pendingResolve = asyncDef().then((def: InnerComponentDef) => {
-        def.configureApp = this._def.configureApp
-        resolve((this._def = def), true)
+      const { configureApp } = this._def
+      this._pendingResolve = asyncDef().then((def: any) => {
+        def.configureApp = configureApp
+        this._def = def
+        resolve(def)
       })
     } else {
       resolve(this._def)
     }
   }
 
-  private _mount(def: InnerComponentDef) {
-    if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) {
-      // @ts-expect-error
-      def.name = 'VueElement'
-    }
-    this._app = this._createApp(def)
-    // inherit before configureApp to detect context overwrites
-    this._inheritParentContext()
-    if (def.configureApp) {
-      def.configureApp(this._app)
-    }
-    this._app._ceVNode = this._createVNode()
-    this._app.mount(this._root)
-
+  private _mountComponent(def: Def): void {
+    this._mount(def)
     // apply expose after mount
+    this._processExposed()
+  }
+
+  protected _processExposed(): void {
     const exposed = this._instance && this._instance.exposed
     if (!exposed) return
     for (const key in exposed) {
@@ -456,7 +469,46 @@ export class VueElement
     }
   }
 
-  private _resolveProps(def: InnerComponentDef) {
+  protected _processInstance(): void {
+    this._instance!.ce = this
+    this._instance!.isCE = true
+
+    if (__DEV__) {
+      this._instance!.ceReload = newStyles => {
+        if (this._styles) {
+          this._styles.forEach(s => this._root.removeChild(s))
+          this._styles.length = 0
+        }
+        this._applyStyles(newStyles)
+        if (!this._instance!.vapor) {
+          this._instance = null
+        }
+        this._update()
+      }
+    }
+
+    const dispatch = (event: string, args: any[]) => {
+      this.dispatchEvent(
+        new CustomEvent(
+          event,
+          isPlainObject(args[0])
+            ? extend({ detail: args }, args[0])
+            : { detail: args },
+        ),
+      )
+    }
+
+    this._instance!.emit = (event: string, ...args: any[]) => {
+      dispatch(event, args)
+      if (hyphenate(event) !== event) {
+        dispatch(hyphenate(event), args)
+      }
+    }
+
+    this._setParent()
+  }
+
+  private _resolveProps(def: Def): void {
     const { props } = def
     const declaredPropKeys = isArray(props) ? props : Object.keys(props || {})
 
@@ -480,7 +532,7 @@ export class VueElement
     }
   }
 
-  protected _setAttr(key: string): void {
+  private _setAttr(key: string): void {
     if (key.startsWith('data-v-')) return
     const has = this.hasAttribute(key)
     let value = has ? this.getAttribute(key) : REMOVAL
@@ -514,7 +566,7 @@ export class VueElement
       } else {
         this._props[key] = val
         // support set key on ceVNode
-        if (key === 'key' && this._app) {
+        if (key === 'key' && this._app && this._app._ceVNode) {
           this._app._ceVNode!.key = val
         }
       }
@@ -540,69 +592,10 @@ export class VueElement
     }
   }
 
-  private _update() {
-    const vnode = this._createVNode()
-    if (this._app) vnode.appContext = this._app._context
-    render(vnode, this._root)
-  }
-
-  private _createVNode(): VNode<any, any> {
-    const baseProps: VNodeProps = {}
-    if (!this.shadowRoot) {
-      baseProps.onVnodeMounted = baseProps.onVnodeUpdated =
-        this._renderSlots.bind(this)
-    }
-    const vnode = createVNode(this._def, extend(baseProps, this._props))
-    if (!this._instance) {
-      vnode.ce = instance => {
-        this._instance = instance
-        instance.ce = this
-        instance.isCE = true // for vue-i18n backwards compat
-        // HMR
-        if (__DEV__) {
-          instance.ceReload = newStyles => {
-            // always reset styles
-            if (this._styles) {
-              this._styles.forEach(s => this._root.removeChild(s))
-              this._styles.length = 0
-            }
-            this._applyStyles(newStyles)
-            this._instance = null
-            this._update()
-          }
-        }
-
-        const dispatch = (event: string, args: any[]) => {
-          this.dispatchEvent(
-            new CustomEvent(
-              event,
-              isPlainObject(args[0])
-                ? extend({ detail: args }, args[0])
-                : { detail: args },
-            ),
-          )
-        }
-
-        // intercept emit
-        instance.emit = (event: string, ...args: any[]) => {
-          // dispatch both the raw and hyphenated versions of an event
-          // to match Vue behavior
-          dispatch(event, args)
-          if (hyphenate(event) !== event) {
-            dispatch(hyphenate(event), args)
-          }
-        }
-
-        this._setParent()
-      }
-    }
-    return vnode
-  }
-
-  private _applyStyles(
+  protected _applyStyles(
     styles: string[] | undefined,
     owner?: ConcreteComponent,
-  ) {
+  ): void {
     if (!styles) return
     if (owner) {
       if (owner === this._def || this._styleChildren.has(owner)) {
@@ -638,7 +631,7 @@ export class VueElement
    * Only called when shadowRoot is false
    */
   private _parseSlots() {
-    const slots: VueElement['_slots'] = (this._slots = {})
+    const slots: VueElementBase['_slots'] = (this._slots = {})
     let n
     while ((n = this.firstChild)) {
       const slotName =
@@ -651,14 +644,18 @@ export class VueElement
   /**
    * Only called when shadowRoot is false
    */
-  private _renderSlots() {
+  protected _renderSlots(): void {
     const outlets = this._getSlots()
     const scopeId = this._instance!.type.__scopeId
+    const slotReplacements: Map<Node, Node[]> = new Map()
+
     for (let i = 0; i < outlets.length; i++) {
       const o = outlets[i] as HTMLSlotElement
       const slotName = o.getAttribute('name') || 'default'
       const content = this._slots![slotName]
       const parent = o.parentNode!
+      const replacementNodes: Node[] = []
+
       if (content) {
         for (const n of content) {
           // for :slotted css
@@ -672,12 +669,20 @@ export class VueElement
             }
           }
           parent.insertBefore(n, o)
+          replacementNodes.push(n)
         }
       } else {
-        while (o.firstChild) parent.insertBefore(o.firstChild, o)
+        while (o.firstChild) {
+          const child = o.firstChild
+          parent.insertBefore(child, o)
+          replacementNodes.push(child)
+        }
       }
       parent.removeChild(o)
+      slotReplacements.set(o, replacementNodes)
     }
+
+    this._updateSlotNodes(slotReplacements)
   }
 
   /**
@@ -743,13 +748,95 @@ export class VueElement
   }
 }
 
-export function useHost(caller?: string): VueElement | null {
-  const instance = getCurrentInstance()
-  const el = instance && (instance.ce as VueElement)
+export class VueElement extends VueElementBase<
+  Element,
+  Component,
+  InnerComponentDef
+> {
+  constructor(
+    def: InnerComponentDef,
+    props: Record<string, any> | undefined = {},
+    createAppFn: CreateAppFunction<Element, Component> = createApp,
+  ) {
+    super(def, props, createAppFn)
+  }
+
+  protected _needsHydration(): boolean {
+    if (this.shadowRoot && this._createApp !== createApp) {
+      return true
+    } else {
+      if (__DEV__ && this.shadowRoot) {
+        warn(
+          `Custom element has pre-rendered declarative shadow root but is not ` +
+            `defined as hydratable. Use \`defineSSRCustomElement\`.`,
+        )
+      }
+    }
+    return false
+  }
+
+  protected _mount(def: InnerComponentDef): void {
+    if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) {
+      // @ts-expect-error
+      def.name = 'VueElement'
+    }
+    this._app = this._createApp(def)
+    this._inheritParentContext()
+    if (def.configureApp) {
+      def.configureApp(this._app)
+    }
+    this._app._ceVNode = this._createVNode()
+    this._app.mount(this._root)
+  }
+
+  protected _update(): void {
+    if (!this._app) return
+    const vnode = this._createVNode()
+    vnode.appContext = this._app._context
+    render(vnode, this._root)
+  }
+
+  protected _unmount(): void {
+    if (this._app) {
+      this._app.unmount()
+    }
+    if (this._instance && this._instance.ce) {
+      this._instance.ce = undefined
+    }
+    this._app = this._instance = null
+  }
+
+  /**
+   * Only called when shadowRoot is false
+   */
+  protected _updateSlotNodes(replacements: Map<Node, Node[]>): void {
+    // do nothing
+  }
+
+  private _createVNode(): VNode<any, any> {
+    const baseProps: VNodeProps = {}
+    if (!this.shadowRoot) {
+      baseProps.onVnodeMounted = baseProps.onVnodeUpdated =
+        this._renderSlots.bind(this)
+    }
+    const vnode = createVNode(this._def, extend(baseProps, this._props))
+    if (!this._instance) {
+      vnode.ce = instance => {
+        this._instance = instance
+        this._processInstance()
+      }
+    }
+    return vnode
+  }
+}
+
+export function useHost(caller?: string): VueElementBase | null {
+  const { hasInstance, value } = useInstanceOption('ce', true)
+  const el = value as VueElementBase
   if (el) {
     return el
   } else if (__DEV__) {
-    if (!instance) {
+    if (!hasInstance) {
       warn(
         `${caller || 'useHost'} called without an active component instance.`,
       )
index f275d65a82d5de3edcc707918ea25adcd9fbec82..784a32172fd288a1e00bfd9a32b6699c9f24d6de 100644 (file)
@@ -261,6 +261,7 @@ export {
   useShadowRoot,
   useHost,
   VueElement,
+  VueElementBase,
   type VueElementConstructor,
   type CustomElementOptions,
 } from './apiCustomElement'
diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts
new file mode 100644 (file)
index 0000000..c109be3
--- /dev/null
@@ -0,0 +1,2110 @@
+import type { MockedFunction } from 'vitest'
+import type { VaporElement } from '../src/apiDefineVaporCustomElement'
+import {
+  type HMRRuntime,
+  type Ref,
+  inject,
+  nextTick,
+  onMounted,
+  provide,
+  ref,
+  toDisplayString,
+  useHost,
+  useShadowRoot,
+} from '@vue/runtime-dom'
+import {
+  VaporTeleport,
+  child,
+  createComponent,
+  createIf,
+  createPlainElement,
+  createSlot,
+  createVaporApp,
+  defineVaporAsyncComponent,
+  defineVaporComponent,
+  defineVaporCustomElement,
+  delegateEvents,
+  next,
+  on,
+  renderEffect,
+  setInsertionState,
+  setText,
+  setValue,
+  template,
+  txt,
+} from '../src'
+
+declare var __VUE_HMR_RUNTIME__: HMRRuntime
+
+describe('defineVaporCustomElement', () => {
+  const container = document.createElement('div')
+  document.body.appendChild(container)
+
+  beforeEach(() => {
+    container.innerHTML = ''
+  })
+
+  delegateEvents('input', 'click', 'mousedown')
+  function render(tag: string, props: any) {
+    const root = document.createElement('div')
+    document.body.appendChild(root)
+    createVaporApp({
+      setup() {
+        return createPlainElement(tag, props, null, true)
+      },
+    }).mount(root)
+
+    return {
+      container: root,
+    }
+  }
+
+  describe('mounting/unmount', () => {
+    const E = defineVaporCustomElement({
+      props: {
+        msg: {
+          type: String,
+          default: 'hello',
+        },
+      },
+      setup(props: any) {
+        const n0 = template('<div> </div>', true)() as any
+        const x0 = txt(n0) as any
+        renderEffect(() => setText(x0, toDisplayString(props.msg)))
+        return n0
+      },
+    })
+    customElements.define('my-element', E)
+
+    test('should work', () => {
+      container.innerHTML = `<my-element></my-element>`
+      const e = container.childNodes[0] as VaporElement
+      expect(e).toBeInstanceOf(E)
+      expect(e._instance).toBeTruthy()
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
+    })
+
+    test('should work w/ manual instantiation', () => {
+      const e = new E({ msg: 'inline' })
+      // should lazy init
+      expect(e._instance).toBe(null)
+      // should initialize on connect
+      container.appendChild(e)
+      expect(e._instance).toBeTruthy()
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>inline</div>`)
+    })
+
+    test('should unmount on remove', async () => {
+      container.innerHTML = `<my-element></my-element>`
+      const e = container.childNodes[0] as VaporElement
+      container.removeChild(e)
+      await nextTick()
+      expect(e._instance).toBe(null)
+      expect(e.shadowRoot!.innerHTML).toBe('')
+    })
+
+    test('When elements move, avoid prematurely disconnecting MutationObserver', async () => {
+      const CustomInput = defineVaporCustomElement({
+        props: ['value'],
+        emits: ['update'],
+        setup(props: any, { emit }: any) {
+          const n0 = template('<input type="number">', true)() as any
+          n0.$evtinput = () => {
+            const num = (n0 as HTMLInputElement).valueAsNumber
+            emit('update', Number.isNaN(num) ? null : num)
+          }
+          renderEffect(() => {
+            setValue(n0, props.value)
+          })
+          return n0
+        },
+      })
+      customElements.define('my-el-input', CustomInput)
+      const num = ref('12')
+      const containerComp = defineVaporComponent({
+        setup() {
+          const n1 = template('<div><div id="move"></div></div>', true)() as any
+          setInsertionState(n1, 0, true)
+          createPlainElement('my-el-input', {
+            value: () => num.value,
+            onInput: () => ($event: CustomEvent) => {
+              num.value = $event.detail[0]
+            },
+          })
+          return n1
+        },
+      })
+      const app = createVaporApp(containerComp)
+      const container = document.createElement('div')
+      document.body.appendChild(container)
+      app.mount(container)
+      const myInputEl = container.querySelector('my-el-input')!
+      const inputEl = myInputEl.shadowRoot!.querySelector('input')!
+      await nextTick()
+      expect(inputEl.value).toBe('12')
+      const moveEl = container.querySelector('#move')!
+      moveEl.append(myInputEl)
+      await nextTick()
+      myInputEl.removeAttribute('value')
+      await nextTick()
+      expect(inputEl.value).toBe('')
+    })
+
+    test('should not unmount on move', async () => {
+      container.innerHTML = `<div><my-element></my-element></div>`
+      const e = container.childNodes[0].childNodes[0] as VaporElement
+      const i = e._instance
+      // moving from one parent to another - this will trigger both disconnect
+      // and connected callbacks synchronously
+      container.appendChild(e)
+      await nextTick()
+      // should be the same instance
+      expect(e._instance).toBe(i)
+      expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
+    })
+
+    test('remove then insert again', async () => {
+      container.innerHTML = `<my-element></my-element>`
+      const e = container.childNodes[0] as VaporElement
+      container.removeChild(e)
+      await nextTick()
+      expect(e._instance).toBe(null)
+      expect(e.shadowRoot!.innerHTML).toBe('')
+      container.appendChild(e)
+      expect(e._instance).toBeTruthy()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
+    })
+  })
+
+  describe('props', () => {
+    const E = defineVaporCustomElement({
+      props: {
+        foo: [String, null],
+        bar: Object,
+        bazQux: null,
+        value: null,
+      },
+      setup(props: any) {
+        const n0 = template('<div> </div>', true)() as any
+        const x0 = txt(n0) as any
+        const n1 = template('<div> </div>', true)() as any
+        const x1 = txt(n1) as any
+
+        renderEffect(() => setText(x0, props.foo || ''))
+        renderEffect(() =>
+          setText(x1, props.bazQux || (props.bar && props.bar.x)),
+        )
+        return [n0, n1]
+      },
+    })
+    customElements.define('my-el-props', E)
+
+    test('renders custom element w/ correct object prop value', () => {
+      const { container } = render('my-el-props', {
+        value: () => ({
+          x: 1,
+        }),
+      })
+
+      const el = container.children[0]
+      expect((el as any).value).toEqual({ x: 1 })
+    })
+
+    test('props via attribute', async () => {
+      // bazQux should map to `baz-qux` attribute
+      container.innerHTML = `<my-el-props foo="hello" baz-qux="bye"></my-el-props>`
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div><div>bye</div>')
+
+      // change attr
+      e.setAttribute('foo', 'changed')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div><div>bye</div>')
+
+      e.setAttribute('baz-qux', 'changed')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        '<div>changed</div><div>changed</div>',
+      )
+    })
+
+    test('props via properties', async () => {
+      // TODO remove this after type inference done
+      const e = new E() as any
+      e.foo = 'one'
+      e.bar = { x: 'two' }
+      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.foo = undefined
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>two</div>')
+      expect(e.hasAttribute('foo')).toBe(false)
+      expect(e.foo).toBe(undefined)
+
+      e.bazQux = 'four'
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>four</div>')
+      expect(e.getAttribute('baz-qux')).toBe('four')
+    })
+
+    test('props via attributes and properties changed together', async () => {
+      // TODO remove this after type inference done
+      const e = new E() as any
+      e.foo = 'foo1'
+      e.bar = { x: 'bar1' }
+      container.appendChild(e)
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>foo1</div><div>bar1</div>')
+
+      // change attr then property
+      e.setAttribute('foo', 'foo2')
+      e.bar = { x: 'bar2' }
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>foo2</div><div>bar2</div>')
+      expect(e.getAttribute('foo')).toBe('foo2')
+      expect(e.hasAttribute('bar')).toBe(false)
+
+      // change prop then attr
+      e.bar = { x: 'bar3' }
+      e.setAttribute('foo', 'foo3')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>foo3</div><div>bar3</div>')
+      expect(e.getAttribute('foo')).toBe('foo3')
+      expect(e.hasAttribute('bar')).toBe(false)
+    })
+
+    test('props via hyphen property', async () => {
+      const Comp = defineVaporCustomElement({
+        props: {
+          fooBar: Boolean,
+        },
+        setup() {
+          return template('Comp')()
+        },
+      })
+      customElements.define('my-el-comp', Comp)
+
+      const { container } = render('my-el-comp', {
+        'foo-bar': () => true,
+      })
+
+      const el = container.children[0]
+      expect((el as any).outerHTML).toBe('<my-el-comp foo-bar=""></my-el-comp>')
+    })
+
+    test('attribute -> prop type casting', async () => {
+      const E = defineVaporCustomElement({
+        props: {
+          fooBar: Number, // test casting of camelCase prop names
+          bar: Boolean,
+          baz: String,
+        },
+        setup(props: any) {
+          const n0 = template(' ')() as any
+          renderEffect(() => {
+            const texts = []
+            texts.push(
+              toDisplayString(props.fooBar),
+              toDisplayString(typeof props.fooBar),
+              toDisplayString(props.bar),
+              toDisplayString(typeof props.bar),
+              toDisplayString(props.baz),
+              toDisplayString(typeof props.baz),
+            )
+            setText(n0, texts.join(' '))
+          })
+          return n0
+        },
+      })
+      customElements.define('my-el-props-cast', E)
+      container.innerHTML = `<my-el-props-cast foo-bar="1" baz="12345"></my-el-props-cast>`
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `1 number false boolean 12345 string`,
+      )
+
+      e.setAttribute('bar', '')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean 12345 string`)
+
+      e.setAttribute('foo-bar', '2e1')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `20 number true boolean 12345 string`,
+      )
+
+      e.setAttribute('baz', '2e1')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean 2e1 string`)
+    })
+
+    test('attr casting w/ programmatic creation', () => {
+      const E = defineVaporCustomElement({
+        props: {
+          foo: Number,
+        },
+        setup(props: any) {
+          const n0 = template(' ')() as any
+          renderEffect(() => {
+            setText(n0, `foo type: ${typeof props.foo}`)
+          })
+          return n0
+        },
+      })
+      customElements.define('my-element-programmatic', E)
+      const el = document.createElement('my-element-programmatic') as any
+      el.setAttribute('foo', '123')
+      container.appendChild(el)
+      expect(el.shadowRoot.innerHTML).toBe(`foo type: number`)
+    })
+
+    test('handling properties set before upgrading', () => {
+      const E = defineVaporCustomElement({
+        props: {
+          foo: String,
+          dataAge: Number,
+        },
+        setup(props: any) {
+          expect(props.foo).toBe('hello')
+          expect(props.dataAge).toBe(5)
+
+          const n0 = template('<div> </div>', true)() as any
+          const x0 = txt(n0) as any
+          renderEffect(() => setText(x0, `foo: ${props.foo}`))
+          return n0
+        },
+      })
+      const el = document.createElement('my-el-upgrade') as any
+      el.foo = 'hello'
+      el.dataset.age = 5
+      el.notProp = 1
+      container.appendChild(el)
+      customElements.define('my-el-upgrade', E)
+      expect(el.shadowRoot.firstChild.innerHTML).toBe(`foo: hello`)
+      // should not reflect if not declared as a prop
+      expect(el.hasAttribute('not-prop')).toBe(false)
+    })
+
+    test('handle properties set before connecting', () => {
+      const obj = { a: 1 }
+      const E = defineVaporCustomElement({
+        props: {
+          foo: String,
+          post: Object,
+        },
+        setup(props: any) {
+          expect(props.foo).toBe('hello')
+          expect(props.post).toBe(obj)
+
+          const n0 = template(' ', true)() as any
+          renderEffect(() => setText(n0, JSON.stringify(props.post)))
+          return n0
+        },
+      })
+      customElements.define('my-el-preconnect', E)
+      const el = document.createElement('my-el-preconnect') as any
+      el.foo = 'hello'
+      el.post = obj
+
+      container.appendChild(el)
+      expect(el.shadowRoot.innerHTML).toBe(JSON.stringify(obj))
+    })
+
+    test('handle components with no props', async () => {
+      const E = defineVaporCustomElement({
+        setup() {
+          return template('<div>foo</div>', true)()
+        },
+      })
+      customElements.define('my-element-noprops', E)
+      const el = document.createElement('my-element-noprops')
+      container.appendChild(el)
+      await nextTick()
+      expect(el.shadowRoot!.innerHTML).toMatchInlineSnapshot('"<div>foo</div>"')
+    })
+
+    test('set number value in dom property', () => {
+      const E = defineVaporCustomElement({
+        props: {
+          'max-age': Number,
+        },
+        setup(props: any) {
+          const n0 = template(' ')() as any
+          renderEffect(() => {
+            setText(n0, `max age: ${props.maxAge}/type: ${typeof props.maxAge}`)
+          })
+          return n0
+        },
+      })
+      customElements.define('my-element-number-property', E)
+      const el = document.createElement('my-element-number-property') as any
+      container.appendChild(el)
+      el.maxAge = 50
+      expect(el.maxAge).toBe(50)
+      expect(el.shadowRoot.innerHTML).toBe('max age: 50/type: number')
+    })
+
+    test('should reflect default value', () => {
+      const E = defineVaporCustomElement({
+        props: {
+          value: {
+            type: String,
+            default: 'hi',
+          },
+        },
+        setup(props: any) {
+          const n0 = template(' ')() as any
+          renderEffect(() => setText(n0, props.value))
+          return n0
+        },
+      })
+      customElements.define('my-el-default-val', E)
+      container.innerHTML = `<my-el-default-val></my-el-default-val>`
+      const e = container.childNodes[0] as any
+      expect(e.value).toBe('hi')
+    })
+
+    test('Boolean prop with default true', async () => {
+      const E = defineVaporCustomElement({
+        props: {
+          foo: {
+            type: Boolean,
+            default: true,
+          },
+        },
+        setup(props: any) {
+          const n0 = template(' ')() as any
+          renderEffect(() => setText(n0, String(props.foo)))
+          return n0
+        },
+      })
+      customElements.define('my-el-default-true', E)
+      container.innerHTML = `<my-el-default-true></my-el-default-true>`
+      const e = container.childNodes[0] as HTMLElement & { foo: any },
+        shadowRoot = e.shadowRoot as ShadowRoot
+      expect(shadowRoot.innerHTML).toBe('true')
+      e.foo = undefined
+      await nextTick()
+      expect(shadowRoot.innerHTML).toBe('true')
+      e.foo = false
+      await nextTick()
+      expect(shadowRoot.innerHTML).toBe('false')
+      e.foo = null
+      await nextTick()
+      expect(shadowRoot.innerHTML).toBe('null')
+      e.foo = ''
+      await nextTick()
+      expect(shadowRoot.innerHTML).toBe('true')
+    })
+
+    test('support direct setup function syntax with extra options', () => {
+      const E = defineVaporCustomElement(
+        (props: any) => {
+          const n0 = template(' ')() as any
+          renderEffect(() => setText(n0, props.text))
+          return n0
+        },
+        {
+          props: {
+            text: String,
+          },
+        },
+      )
+      customElements.define('my-el-setup-with-props', E)
+      container.innerHTML = `<my-el-setup-with-props text="hello"></my-el-setup-with-props>`
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot!.innerHTML).toBe('hello')
+    })
+
+    test('prop types validation', async () => {
+      const E = defineVaporCustomElement({
+        props: {
+          num: {
+            type: [Number, String],
+          },
+          bool: {
+            type: Boolean,
+          },
+        },
+        setup(props: any) {
+          const n0 = template(
+            '<div><span> </span><span> </span></div>',
+            true,
+          )() as any
+          const n1 = child(n0) as any
+          const n2 = next(n1) as any
+          const x0 = txt(n1) as any
+          const x1 = txt(n2) as any
+          renderEffect(() => setText(x0, `${props.num} is ${typeof props.num}`))
+          renderEffect(() =>
+            setText(x1, `${props.bool} is ${typeof props.bool}`),
+          )
+          return n0
+        },
+      })
+
+      customElements.define('my-el-with-type-props', E)
+      const { container } = render('my-el-with-type-props', {
+        num: () => 1,
+        bool: () => true,
+      })
+      const e = container.childNodes[0] as VaporElement
+      // @ts-expect-error
+      expect(e.num).toBe(1)
+      // @ts-expect-error
+      expect(e.bool).toBe(true)
+      expect(e.shadowRoot!.innerHTML).toBe(
+        '<div><span>1 is number</span><span>true is boolean</span></div>',
+      )
+    })
+  })
+
+  describe('attrs', () => {
+    const E = defineVaporCustomElement({
+      setup(_: any, { attrs }: any) {
+        const n0 = template('<div> </div>')() as any
+        const x0 = txt(n0) as any
+        renderEffect(() => setText(x0, toDisplayString(attrs.foo)))
+        return [n0]
+      },
+    })
+    customElements.define('my-el-attrs', E)
+
+    test('attrs via attribute', async () => {
+      container.innerHTML = `<my-el-attrs foo="hello"></my-el-attrs>`
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
+
+      e.setAttribute('foo', 'changed')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div>')
+    })
+
+    test('non-declared properties should not show up in $attrs', () => {
+      const e = new E()
+      // @ts-expect-error
+      e.foo = '123'
+      container.appendChild(e)
+      expect(e.shadowRoot!.innerHTML).toBe('<div></div>')
+    })
+
+    // https://github.com/vuejs/core/issues/12964
+    // Disabled because of missing support for `delegatesFocus` in jsdom
+    // https://github.com/jsdom/jsdom/issues/3418
+    // use vitest browser mode instead
+    test.todo('shadowRoot should be initialized with delegatesFocus', () => {
+      const E = defineVaporCustomElement(
+        {
+          setup() {
+            return template('<input tabindex="1">', true)()
+          },
+        },
+        { shadowRootOptions: { delegatesFocus: true } } as any,
+      )
+      customElements.define('my-el-with-delegate-focus', E)
+
+      const e = new E()
+      container.appendChild(e)
+      expect(e.shadowRoot!.delegatesFocus).toBe(true)
+    })
+  })
+
+  describe('emits', () => {
+    const CompDef = defineVaporComponent({
+      setup(_, { emit }) {
+        emit('created')
+        const n0 = template('<div></div>', true)() as any
+        n0.$evtclick = () => {
+          emit('my-click', 1)
+        }
+        n0.$evtmousedown = () => {
+          emit('myEvent', 1) // validate hyphenation
+        }
+        on(n0, 'wheel', () => {
+          emit('my-wheel', { bubbles: true }, 1)
+        })
+        return n0
+      },
+    })
+    const E = defineVaporCustomElement(CompDef)
+    customElements.define('my-el-emits', E)
+
+    test('emit on connect', () => {
+      const e = new E()
+      const spy = vi.fn()
+      e.addEventListener('created', spy)
+      container.appendChild(e)
+      expect(spy).toHaveBeenCalled()
+    })
+
+    test('emit on interaction', () => {
+      container.innerHTML = `<my-el-emits></my-el-emits>`
+      const e = container.childNodes[0] as VaporElement
+      const spy = vi.fn()
+      e.addEventListener('my-click', spy)
+      // Use click() method which triggers a real click event
+      // with bubbles: true and composed: true
+      ;(e.shadowRoot!.childNodes[0] as HTMLElement).click()
+      expect(spy).toHaveBeenCalledTimes(1)
+      expect(spy.mock.calls[0][0]).toMatchObject({
+        detail: [1],
+      })
+    })
+
+    test('case transform for camelCase event', () => {
+      container.innerHTML = `<my-el-emits></my-el-emits>`
+      const e = container.childNodes[0] as VaporElement
+      const spy1 = vi.fn()
+      e.addEventListener('myEvent', spy1)
+      const spy2 = vi.fn()
+      // emitting myEvent, but listening for my-event. This happens when
+      // using the custom element in a Vue template
+      e.addEventListener('my-event', spy2)
+      e.shadowRoot!.childNodes[0].dispatchEvent(
+        new CustomEvent('mousedown', {
+          bubbles: true,
+          composed: true,
+        }),
+      )
+      expect(spy1).toHaveBeenCalledTimes(1)
+      expect(spy2).toHaveBeenCalledTimes(1)
+    })
+
+    test('emit from within async component wrapper', async () => {
+      const p = new Promise<typeof CompDef>(res => res(CompDef as any))
+      const E = defineVaporCustomElement(defineVaporAsyncComponent(() => p))
+      customElements.define('my-async-el-emits', E)
+      container.innerHTML = `<my-async-el-emits></my-async-el-emits>`
+      const e = container.childNodes[0] as VaporElement
+      const spy = vi.fn()
+      e.addEventListener('my-click', spy)
+      // this feels brittle but seems necessary to reach the node in the DOM.
+      await customElements.whenDefined('my-async-el-emits')
+      await nextTick()
+      await nextTick()
+      e.shadowRoot!.childNodes[0].dispatchEvent(
+        new CustomEvent('click', {
+          bubbles: true,
+          composed: true,
+        }),
+      )
+      expect(spy).toHaveBeenCalled()
+      expect(spy.mock.calls[0][0]).toMatchObject({
+        detail: [1],
+      })
+    })
+
+    test('emit in an async component wrapper with properties bound', async () => {
+      const E = defineVaporCustomElement(
+        defineVaporAsyncComponent(
+          () => new Promise<typeof CompDef>(res => res(CompDef as any)),
+        ),
+      )
+      customElements.define('my-async-el-props-emits', E)
+      container.innerHTML = `<my-async-el-props-emits id="my_async_el_props_emits"></my-async-el-props-emits>`
+      const e = container.childNodes[0] as VaporElement
+      const spy = vi.fn()
+      e.addEventListener('my-click', spy)
+      await customElements.whenDefined('my-async-el-props-emits')
+      await nextTick()
+      await nextTick()
+      e.shadowRoot!.childNodes[0].dispatchEvent(
+        new CustomEvent('click', {
+          bubbles: true,
+          composed: true,
+        }),
+      )
+      expect(spy).toHaveBeenCalled()
+      expect(spy.mock.calls[0][0]).toMatchObject({
+        detail: [1],
+      })
+    })
+
+    test('emit with options', async () => {
+      container.innerHTML = `<my-el-emits></my-el-emits>`
+      const e = container.childNodes[0] as VaporElement
+      const spy = vi.fn()
+      e.addEventListener('my-wheel', spy)
+      e.shadowRoot!.childNodes[0].dispatchEvent(
+        new CustomEvent('wheel', {
+          bubbles: true,
+          composed: true,
+        }),
+      )
+      expect(spy).toHaveBeenCalledTimes(1)
+      expect(spy.mock.calls[0][0]).toMatchObject({
+        bubbles: true,
+        detail: [{ bubbles: true }, 1],
+      })
+    })
+  })
+
+  describe('slots', () => {
+    const E = defineVaporCustomElement({
+      setup() {
+        const t0 = template('<div>fallback</div>')
+        const t1 = template('<div></div>')
+        const n3 = t1() as any
+        setInsertionState(n3, null, true)
+        createSlot('default', null, () => {
+          const n2 = t0()
+          return n2
+        })
+        const n5 = t1() as any
+        setInsertionState(n5, null, true)
+        createSlot('named', null)
+        return [n3, n5]
+      },
+    })
+    customElements.define('my-el-slots', E)
+
+    test('render slots correctly', () => {
+      container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
+      const e = container.childNodes[0] as VaporElement
+      // native slots allocation does not affect innerHTML, so we just
+      // verify that we've rendered the correct native slots here...
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div>` +
+          `<slot><div>fallback</div></slot><!--slot-->` +
+          `</div>` +
+          `<div>` +
+          `<slot name="named"></slot><!--slot-->` +
+          `</div>`,
+      )
+    })
+
+    test('render slot props', async () => {
+      const foo = ref('foo')
+      const E = defineVaporCustomElement({
+        setup() {
+          const n0 = template('<div></div>')() as any
+          setInsertionState(n0, null)
+          createSlot('default', { class: () => foo.value })
+          return [n0]
+        },
+      })
+      customElements.define('my-el-slot-props', E)
+      container.innerHTML = `<my-el-slot-props><span>hi</span></my-el-slot-props>`
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><slot class="foo"></slot><!--slot--></div>`,
+      )
+
+      foo.value = 'bar'
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><slot class="bar"></slot><!--slot--></div>`,
+      )
+    })
+  })
+
+  describe('provide/inject', () => {
+    const Consumer = defineVaporCustomElement({
+      setup() {
+        const foo = inject<Ref>('foo')!
+        const n0 = template('<div> </div>', true)() as any
+        const x0 = txt(n0) as any
+        renderEffect(() => setText(x0, toDisplayString(foo.value)))
+        return n0
+      },
+    })
+    customElements.define('my-consumer', Consumer)
+
+    test('over nested usage', async () => {
+      const foo = ref('injected!')
+      const Provider = defineVaporCustomElement({
+        setup() {
+          provide('foo', foo)
+          return createPlainElement('my-consumer')
+        },
+      })
+      customElements.define('my-provider', Provider)
+      container.innerHTML = `<my-provider><my-provider>`
+      const provider = container.childNodes[0] as VaporElement
+      const consumer = provider.shadowRoot!.childNodes[0] as VaporElement
+
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
+
+      foo.value = 'changed!'
+      await nextTick()
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
+    })
+
+    test('over slot composition', async () => {
+      const foo = ref('injected!')
+      const Provider = defineVaporCustomElement({
+        setup() {
+          provide('foo', foo)
+          return createSlot('default', null)
+        },
+      })
+      customElements.define('my-provider-2', Provider)
+
+      container.innerHTML = `<my-provider-2><my-consumer></my-consumer><my-provider-2>`
+      const provider = container.childNodes[0]
+      const consumer = provider.childNodes[0] as VaporElement
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
+
+      foo.value = 'changed!'
+      await nextTick()
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
+    })
+
+    test('inherited from ancestors', async () => {
+      const fooA = ref('FooA!')
+      const fooB = ref('FooB!')
+      const ProviderA = defineVaporCustomElement({
+        setup() {
+          provide('fooA', fooA)
+          return createPlainElement('provider-b')
+        },
+      })
+      const ProviderB = defineVaporCustomElement({
+        setup() {
+          provide('fooB', fooB)
+          return createPlainElement('my-multi-consumer')
+        },
+      })
+
+      const Consumer = defineVaporCustomElement({
+        setup() {
+          const fooA = inject<Ref>('fooA')!
+          const fooB = inject<Ref>('fooB')!
+          const n0 = template('<div> </div>', true)() as any
+          const x0 = txt(n0) as any
+          renderEffect(() => setText(x0, `${fooA.value} ${fooB.value}`))
+          return n0
+        },
+      })
+
+      customElements.define('provider-a', ProviderA)
+      customElements.define('provider-b', ProviderB)
+      customElements.define('my-multi-consumer', Consumer)
+      container.innerHTML = `<provider-a><provider-a>`
+      const providerA = container.childNodes[0] as VaporElement
+      const providerB = providerA.shadowRoot!.childNodes[0] as VaporElement
+      const consumer = providerB.shadowRoot!.childNodes[0] as VaporElement
+
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>FooA! FooB!</div>`)
+
+      fooA.value = 'changedA!'
+      fooB.value = 'changedB!'
+      await nextTick()
+      expect(consumer.shadowRoot!.innerHTML).toBe(
+        `<div>changedA! changedB!</div>`,
+      )
+    })
+
+    test('inherited from app context within nested elements', async () => {
+      const outerValues: (string | undefined)[] = []
+      const innerValues: (string | undefined)[] = []
+      const innerChildValues: (string | undefined)[] = []
+
+      const Outer = defineVaporCustomElement(
+        {
+          setup() {
+            outerValues.push(
+              inject<string>('shared'),
+              inject<string>('outer'),
+              inject<string>('inner'),
+            )
+
+            const n0 = template('<div></div>', true)() as any
+            setInsertionState(n0, null)
+            createSlot('default', null)
+            return n0
+          },
+        },
+        {
+          configureApp(app: any) {
+            app.provide('shared', 'shared')
+            app.provide('outer', 'outer')
+          },
+        } as any,
+      )
+
+      const Inner = defineVaporCustomElement(
+        {
+          setup() {
+            // ensure values are not self-injected
+            provide('inner', 'inner-child')
+
+            innerValues.push(
+              inject<string>('shared'),
+              inject<string>('outer'),
+              inject<string>('inner'),
+            )
+            const n0 = template('<div></div>', true)() as any
+            setInsertionState(n0, null)
+            createSlot('default', null)
+            return n0
+          },
+        },
+        {
+          configureApp(app: any) {
+            app.provide('outer', 'override-outer')
+            app.provide('inner', 'inner')
+          },
+        } as any,
+      )
+
+      const InnerChild = defineVaporCustomElement({
+        setup() {
+          innerChildValues.push(
+            inject<string>('shared'),
+            inject<string>('outer'),
+            inject<string>('inner'),
+          )
+          const n0 = template('<div></div>', true)() as any
+          return n0
+        },
+      })
+
+      customElements.define('provide-from-app-outer', Outer)
+      customElements.define('provide-from-app-inner', Inner)
+      customElements.define('provide-from-app-inner-child', InnerChild)
+
+      container.innerHTML =
+        '<provide-from-app-outer>' +
+        '<provide-from-app-inner>' +
+        '<provide-from-app-inner-child></provide-from-app-inner-child>' +
+        '</provide-from-app-inner>' +
+        '</provide-from-app-outer>'
+
+      const outer = container.childNodes[0] as VaporElement
+      expect(outer.shadowRoot!.innerHTML).toBe(
+        '<div><slot></slot><!--slot--></div>',
+      )
+
+      expect('[Vue warn]: injection "inner" not found.').toHaveBeenWarnedTimes(
+        1,
+      )
+      expect(
+        '[Vue warn]: App already provides property with key "outer" inherited from its parent element. ' +
+          'It will be overwritten with the new value.',
+      ).toHaveBeenWarnedTimes(1)
+
+      expect(outerValues).toEqual(['shared', 'outer', undefined])
+      expect(innerValues).toEqual(['shared', 'override-outer', 'inner'])
+      expect(innerChildValues).toEqual([
+        'shared',
+        'override-outer',
+        'inner-child',
+      ])
+    })
+  })
+
+  describe('styles', () => {
+    function assertStyles(el: VaporElement, css: string[]) {
+      const styles = el.shadowRoot?.querySelectorAll('style')!
+      expect(styles.length).toBe(css.length) // should not duplicate multiple copies from Bar
+      for (let i = 0; i < css.length; i++) {
+        expect(styles[i].textContent).toBe(css[i])
+      }
+    }
+
+    test('should attach styles to shadow dom', async () => {
+      const def = defineVaporComponent({
+        __hmrId: 'foo',
+        styles: [`div { color: red; }`],
+        setup() {
+          return template('<div>hello</div>', true)()
+        },
+      } as any)
+      const Foo = defineVaporCustomElement(def)
+      customElements.define('my-el-with-styles', Foo)
+      container.innerHTML = `<my-el-with-styles></my-el-with-styles>`
+      const el = container.childNodes[0] as VaporElement
+      const style = el.shadowRoot?.querySelector('style')!
+      expect(style.textContent).toBe(`div { color: red; }`)
+
+      // hmr
+      __VUE_HMR_RUNTIME__.reload('foo', {
+        ...def,
+        styles: [`div { color: blue; }`, `div { color: yellow; }`],
+      } as any)
+
+      await nextTick()
+      assertStyles(el, [`div { color: blue; }`, `div { color: yellow; }`])
+    })
+
+    test("child components should inject styles to root element's shadow root", async () => {
+      const Baz = () => createComponent(Bar)
+      const Bar = defineVaporComponent({
+        __hmrId: 'bar',
+        styles: [`div { color: green; }`, `div { color: blue; }`],
+        setup() {
+          return template('bar')()
+        },
+      } as any)
+      const Foo = defineVaporCustomElement({
+        styles: [`div { color: red; }`],
+        setup() {
+          return [createComponent(Baz), createComponent(Baz)]
+        },
+      })
+      customElements.define('my-el-with-child-styles', Foo)
+      container.innerHTML = `<my-el-with-child-styles></my-el-with-child-styles>`
+      const el = container.childNodes[0] as VaporElement
+
+      // inject order should be child -> parent
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: blue; }`,
+        `div { color: red; }`,
+      ])
+
+      // hmr
+      __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
+        ...Bar,
+        styles: [`div { color: red; }`, `div { color: yellow; }`],
+      } as any)
+
+      await nextTick()
+      assertStyles(el, [
+        `div { color: red; }`,
+        `div { color: yellow; }`,
+        `div { color: red; }`,
+      ])
+
+      __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
+        ...Bar,
+        styles: [`div { color: blue; }`],
+      } as any)
+      await nextTick()
+      assertStyles(el, [`div { color: blue; }`, `div { color: red; }`])
+    })
+
+    test("child components should not inject styles to root element's shadow root w/ shadowRoot false", async () => {
+      const Bar = defineVaporComponent({
+        styles: [`div { color: green; }`],
+        setup() {
+          return template('bar')()
+        },
+      } as any)
+      const Baz = () => createComponent(Bar)
+      const Foo = defineVaporCustomElement(
+        {
+          setup() {
+            return [createComponent(Baz)]
+          },
+        },
+        { shadowRoot: false } as any,
+      )
+
+      customElements.define('my-foo-with-shadowroot-false', Foo)
+      container.innerHTML = `<my-foo-with-shadowroot-false></my-foo-with-shadowroot-false>`
+      const el = container.childNodes[0] as VaporElement
+      const style = el.shadowRoot?.querySelector('style')
+      expect(style).toBeUndefined()
+    })
+
+    test('with nonce', () => {
+      const Foo = defineVaporCustomElement(
+        {
+          styles: [`div { color: red; }`],
+          setup() {
+            return template('<div>hello</div>', true)()
+          },
+        },
+        { nonce: 'xxx' } as any,
+      )
+      customElements.define('my-el-with-nonce', Foo)
+      container.innerHTML = `<my-el-with-nonce></my-el-with-nonce>`
+      const el = container.childNodes[0] as VaporElement
+      const style = el.shadowRoot?.querySelector('style')!
+      expect(style.getAttribute('nonce')).toBe('xxx')
+    })
+  })
+
+  describe('async', () => {
+    test('should work', async () => {
+      const loaderSpy = vi.fn()
+      const E = defineVaporCustomElement(
+        defineVaporAsyncComponent(() => {
+          loaderSpy()
+          return Promise.resolve({
+            props: ['msg'],
+            styles: [`div { color: red }`],
+            setup(props: any) {
+              const n0 = template('<div> </div>', true)() as any
+              const x0 = txt(n0) as any
+              renderEffect(() => setText(x0, props.msg))
+              return n0
+            },
+          })
+        }),
+      )
+      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 VaporElement
+      const e2 = container.childNodes[1] as VaporElement
+
+      // should inject styles
+      expect(e1.shadowRoot!.innerHTML).toBe(
+        `<style>div { color: red }</style><div>hello</div>`,
+      )
+      expect(e2.shadowRoot!.innerHTML).toBe(
+        `<style>div { color: red }</style><div>world</div>`,
+      )
+
+      // attr
+      e1.setAttribute('msg', 'attr')
+      await nextTick()
+      expect((e1 as any).msg).toBe('attr')
+      expect(e1.shadowRoot!.innerHTML).toBe(
+        `<style>div { color: red }</style><div>attr</div>`,
+      )
+
+      // props
+      expect(`msg` in e1).toBe(true)
+      ;(e1 as any).msg = 'prop'
+      expect(e1.getAttribute('msg')).toBe('prop')
+      expect(e1.shadowRoot!.innerHTML).toBe(
+        `<style>div { color: red }</style><div>prop</div>`,
+      )
+    })
+
+    test('set DOM property before resolve', async () => {
+      const E = defineVaporCustomElement(
+        defineVaporAsyncComponent(() => {
+          return Promise.resolve({
+            props: ['msg'],
+            setup(props: any) {
+              expect(typeof props.msg).toBe('string')
+              const n0 = template('<div> </div>', true)() as any
+              const x0 = txt(n0) as any
+              renderEffect(() => setText(x0, props.msg))
+              return n0
+            },
+          })
+        }),
+      )
+      customElements.define('my-el-async-2', E)
+
+      const e1 = new E() as any
+
+      // set property before connect
+      e1.msg = 'hello'
+
+      const e2 = new E() as any
+
+      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>`)
+    })
+
+    test('Number prop casting before resolve', async () => {
+      const E = defineVaporCustomElement(
+        defineVaporAsyncComponent(() => {
+          return Promise.resolve({
+            props: { n: Number },
+            setup(props: any) {
+              expect(props.n).toBe(20)
+              const n0 = template('<div> </div>', true)() as any
+              const x0 = txt(n0) as any
+              renderEffect(() => setText(x0, `${props.n},${typeof props.n}`))
+              return n0
+            },
+          })
+        }),
+      )
+      customElements.define('my-el-async-3', E)
+      container.innerHTML = `<my-el-async-3 n="2e1"></my-el-async-3>`
+
+      await new Promise(r => setTimeout(r))
+
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>20,number</div>`)
+    })
+
+    test('with slots', async () => {
+      const E = defineVaporCustomElement(
+        defineVaporAsyncComponent(() => {
+          return Promise.resolve({
+            setup() {
+              const t0 = template('<div>fallback</div>')
+              const t1 = template('<div></div>')
+              const n3 = t1() as any
+              setInsertionState(n3, null)
+              createSlot('default', null, () => {
+                const n2 = t0()
+                return n2
+              })
+              const n5 = t1() as any
+              setInsertionState(n5, null)
+              createSlot('named', null)
+              return [n3, n5]
+            },
+          })
+        }),
+      )
+      customElements.define('my-el-async-slots', E)
+      container.innerHTML = `<my-el-async-slots><span>hi</span></my-el-async-slots>`
+
+      await new Promise(r => setTimeout(r))
+
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div>` +
+          `<slot><div>fallback</div></slot><!--slot-->` +
+          `</div><div>` +
+          `<slot name="named"></slot><!--slot-->` +
+          `</div>`,
+      )
+    })
+  })
+
+  describe('shadowRoot: false', () => {
+    const E = defineVaporCustomElement({
+      shadowRoot: false,
+      props: {
+        msg: {
+          type: String,
+          default: 'hello',
+        },
+      },
+      setup(props: any) {
+        const n0 = template('<div> </div>')() as any
+        const x0 = txt(n0) as any
+        renderEffect(() => setText(x0, toDisplayString(props.msg)))
+        return n0
+      },
+    })
+    customElements.define('my-el-shadowroot-false', E)
+
+    test('should work', async () => {
+      function raf() {
+        return new Promise(resolve => {
+          requestAnimationFrame(resolve)
+        })
+      }
+
+      container.innerHTML = `<my-el-shadowroot-false></my-el-shadowroot-false>`
+      const e = container.childNodes[0] as VaporElement
+      await raf()
+      expect(e).toBeInstanceOf(E)
+      expect(e._instance).toBeTruthy()
+      expect(e.innerHTML).toBe(`<div>hello</div>`)
+      expect(e.shadowRoot).toBe(null)
+    })
+
+    const toggle = ref(true)
+    const ES = defineVaporCustomElement(
+      {
+        setup() {
+          const n0 = createSlot('default')
+          const n1 = createIf(
+            () => toggle.value,
+            () => createSlot('named'),
+          )
+          const n2 = createSlot('omitted', null, () =>
+            template('<div>fallback</div>')(),
+          )
+          return [n0, n1, n2]
+        },
+      },
+      { shadowRoot: false } as any,
+    )
+    customElements.define('my-el-shadowroot-false-slots', ES)
+
+    test('should render slots', async () => {
+      container.innerHTML =
+        `<my-el-shadowroot-false-slots>` +
+        `<span>default</span>text` +
+        `<div slot="named">named</div>` +
+        `</my-el-shadowroot-false-slots>`
+      const e = container.childNodes[0] as VaporElement
+      // native slots allocation does not affect innerHTML, so we just
+      // verify that we've rendered the correct native slots here...
+      expect(e.innerHTML).toBe(
+        `<span>default</span>text<!--slot-->` +
+          `<div slot="named">named</div><!--slot--><!--if-->` +
+          `<div>fallback</div><!--slot-->`,
+      )
+
+      toggle.value = false
+      await nextTick()
+      expect(e.innerHTML).toBe(
+        `<span>default</span>text<!--slot-->` +
+          `<!--if-->` +
+          `<div>fallback</div><!--slot-->`,
+      )
+    })
+
+    test('render nested customElement w/ shadowRoot false', async () => {
+      const calls: string[] = []
+
+      const Child = defineVaporCustomElement(
+        {
+          setup() {
+            calls.push('child rendering')
+            onMounted(() => {
+              calls.push('child mounted')
+            })
+            return createSlot('default')
+          },
+        },
+        { shadowRoot: false } as any,
+      )
+      customElements.define('my-child', Child)
+
+      const Parent = defineVaporCustomElement(
+        {
+          setup() {
+            calls.push('parent rendering')
+            onMounted(() => {
+              calls.push('parent mounted')
+            })
+            return createSlot('default')
+          },
+        },
+        { shadowRoot: false } as any,
+      )
+      customElements.define('my-parent', Parent)
+
+      const App = {
+        setup() {
+          return createPlainElement('my-parent', null, {
+            default: () =>
+              createPlainElement('my-child', null, {
+                default: () => template('<span>default</span>')(),
+              }),
+          })
+        },
+      }
+      const app = createVaporApp(App)
+      app.mount(container)
+      await nextTick()
+      const e = container.childNodes[0] as VaporElement
+      expect(e.innerHTML).toBe(
+        `<my-child data-v-app=""><span>default</span><!--slot--></my-child><!--slot-->`,
+      )
+      expect(calls).toEqual([
+        'parent rendering',
+        'parent mounted',
+        'child rendering',
+        'child mounted',
+      ])
+      app.unmount()
+    })
+
+    test('render nested Teleport w/ shadowRoot false', async () => {
+      const target = document.createElement('div')
+      const Child = defineVaporCustomElement(
+        {
+          setup() {
+            return createComponent(
+              VaporTeleport,
+              { to: () => target },
+              {
+                default: () => createSlot('default'),
+              },
+            )
+          },
+        },
+        { shadowRoot: false } as any,
+      )
+      customElements.define('my-el-teleport-child', Child)
+      const Parent = defineVaporCustomElement(
+        {
+          setup() {
+            return createSlot('default')
+          },
+        },
+        { shadowRoot: false } as any,
+      )
+      customElements.define('my-el-teleport-parent', Parent)
+
+      const App = {
+        setup() {
+          return createPlainElement('my-el-teleport-parent', null, {
+            default: () =>
+              createPlainElement('my-el-teleport-child', null, {
+                default: () => template('<span>default</span>')(),
+              }),
+          })
+        },
+      }
+      const app = createVaporApp(App)
+      app.mount(container)
+      await nextTick()
+      expect(target.innerHTML).toBe(`<span>default</span><!--slot-->`)
+      app.unmount()
+    })
+
+    test('render two Teleports w/ shadowRoot false', async () => {
+      const target1 = document.createElement('div')
+      const target2 = document.createElement('span')
+      const Child = defineVaporCustomElement(
+        {
+          setup() {
+            return [
+              createComponent(
+                VaporTeleport,
+                { to: () => target1 },
+                {
+                  default: () => createSlot('header'),
+                },
+              ),
+              createComponent(
+                VaporTeleport,
+                { to: () => target2 },
+                {
+                  default: () => createSlot('body'),
+                },
+              ),
+            ]
+          },
+        },
+        { shadowRoot: false } as any,
+      )
+      customElements.define('my-el-two-teleport-child', Child)
+
+      const App = {
+        setup() {
+          return createPlainElement('my-el-two-teleport-child', null, {
+            default: () => [
+              template('<div slot="header">header</div>')(),
+              template('<span slot="body">body</span>')(),
+            ],
+          })
+        },
+      }
+      const app = createVaporApp(App)
+      app.mount(container)
+      await nextTick()
+      expect(target1.outerHTML).toBe(
+        `<div><div slot="header">header</div><!--slot--></div>`,
+      )
+      expect(target2.outerHTML).toBe(
+        `<span><span slot="body">body</span><!--slot--></span>`,
+      )
+      app.unmount()
+    })
+
+    test('render two Teleports w/ shadowRoot false (with disabled)', async () => {
+      const target1 = document.createElement('div')
+      const target2 = document.createElement('span')
+      const Child = defineVaporCustomElement(
+        {
+          setup() {
+            return [
+              createComponent(
+                VaporTeleport,
+                // with disabled: true
+                { to: () => target1, disabled: () => true },
+                {
+                  default: () => createSlot('header'),
+                },
+              ),
+              createComponent(
+                VaporTeleport,
+                { to: () => target2 },
+                {
+                  default: () => createSlot('body'),
+                },
+              ),
+            ]
+          },
+        },
+        { shadowRoot: false } as any,
+      )
+      customElements.define('my-el-two-teleport-child-0', Child)
+
+      const App = {
+        setup() {
+          return createPlainElement('my-el-two-teleport-child-0', null, {
+            default: () => [
+              template('<div slot="header">header</div>')(),
+              template('<span slot="body">body</span>')(),
+            ],
+          })
+        },
+      }
+      const app = createVaporApp(App)
+      app.mount(container)
+      await nextTick()
+      expect(target1.outerHTML).toBe(`<div></div>`)
+      expect(target2.outerHTML).toBe(
+        `<span><span slot="body">body</span><!--slot--></span>`,
+      )
+      app.unmount()
+    })
+
+    test('toggle nested custom element with shadowRoot: false', async () => {
+      customElements.define(
+        'my-el-child-shadow-false',
+        defineVaporCustomElement(
+          {
+            setup() {
+              const n0 = template('<div></div>')() as any
+              setInsertionState(n0, null)
+              createSlot('default', null)
+              return n0
+            },
+          },
+          { shadowRoot: false } as any,
+        ),
+      )
+      const ChildWrapper = {
+        setup() {
+          return createPlainElement('my-el-child-shadow-false', null, {
+            default: () => template('child')(),
+          })
+        },
+      }
+
+      customElements.define(
+        'my-el-parent-shadow-false',
+        defineVaporCustomElement(
+          {
+            props: {
+              isShown: { type: Boolean, required: true },
+            },
+            setup(props: any) {
+              return createIf(
+                () => props.isShown,
+                () => {
+                  const n0 = template('<div></div>')() as any
+                  setInsertionState(n0, null)
+                  createSlot('default', null)
+                  return n0
+                },
+              )
+            },
+          },
+          { shadowRoot: false } as any,
+        ),
+      )
+      const ParentWrapper = {
+        props: {
+          isShown: { type: Boolean, required: true },
+        },
+        setup(props: any) {
+          return createPlainElement(
+            'my-el-parent-shadow-false',
+            { isShown: () => props.isShown },
+            {
+              default: () => createSlot('default'),
+            },
+          )
+        },
+      }
+
+      const isShown = ref(true)
+      const App = {
+        setup() {
+          return createComponent(
+            ParentWrapper,
+            { isShown: () => isShown.value },
+            {
+              default: () => createComponent(ChildWrapper),
+            },
+          )
+        },
+      }
+      const container = document.createElement('div')
+      document.body.appendChild(container)
+      const app = createVaporApp(App)
+      app.mount(container)
+      expect(container.innerHTML).toBe(
+        `<my-el-parent-shadow-false is-shown="" data-v-app="">` +
+          `<div>` +
+          `<my-el-child-shadow-false data-v-app="">` +
+          `<div>child<!--slot--></div>` +
+          `</my-el-child-shadow-false><!--slot--><!--slot-->` +
+          `</div><!--if-->` +
+          `</my-el-parent-shadow-false>`,
+      )
+
+      isShown.value = false
+      await nextTick()
+      expect(container.innerHTML).toBe(
+        `<my-el-parent-shadow-false data-v-app=""><!--if--></my-el-parent-shadow-false>`,
+      )
+
+      isShown.value = true
+      await nextTick()
+      expect(container.innerHTML).toBe(
+        `<my-el-parent-shadow-false data-v-app="" is-shown="">` +
+          `<div>` +
+          `<my-el-child-shadow-false data-v-app="">` +
+          `<div>child<!--slot--></div>` +
+          `</my-el-child-shadow-false><!--slot--><!--slot-->` +
+          `</div><!--if-->` +
+          `</my-el-parent-shadow-false>`,
+      )
+    })
+  })
+
+  describe('helpers', () => {
+    test('useHost', () => {
+      const Foo = defineVaporCustomElement({
+        setup() {
+          const host = useHost()!
+          host.setAttribute('id', 'host')
+          return template('<div>hello</div>')()
+        },
+      })
+      customElements.define('my-el-use-host', Foo)
+      container.innerHTML = `<my-el-use-host>`
+      const el = container.childNodes[0] as VaporElement
+      expect(el.id).toBe('host')
+    })
+
+    test('useShadowRoot for style injection', () => {
+      const Foo = defineVaporCustomElement({
+        setup() {
+          const root = useShadowRoot()!
+          const style = document.createElement('style')
+          style.innerHTML = `div { color: red; }`
+          root.appendChild(style)
+          return template('<div>hello</div>')()
+        },
+      })
+      customElements.define('my-el-use-shadow-root', Foo)
+      container.innerHTML = `<my-el-use-shadow-root>`
+      const el = container.childNodes[0] as VaporElement
+      const style = el.shadowRoot?.querySelector('style')!
+      expect(style.textContent).toBe(`div { color: red; }`)
+    })
+  })
+
+  describe('expose', () => {
+    test('expose w/ options api', async () => {
+      const E = defineVaporCustomElement({
+        setup(_: any, { expose }: any) {
+          const value = ref(0)
+          const foo = () => {
+            value.value++
+          }
+          expose({ foo })
+          const n0 = template('<div> </div>', true)() as any
+          const x0 = txt(n0) as any
+          renderEffect(() => setText(x0, `${value.value}`))
+          return n0
+        },
+      })
+      customElements.define('my-el-expose-options-api', E)
+
+      container.innerHTML = `<my-el-expose-options-api></my-el-expose-options-api>`
+      const e = container.childNodes[0] as VaporElement & {
+        foo: () => void
+      }
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>0</div>`)
+      e.foo()
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>1</div>`)
+    })
+
+    test('expose attributes and callback', async () => {
+      type SetValue = (value: string) => void
+      let fn: MockedFunction<SetValue>
+
+      const E = defineVaporCustomElement({
+        setup(_: any, { expose }: any) {
+          const value = ref('hello')
+
+          const setValue = (fn = vi.fn((_value: string) => {
+            value.value = _value
+          }))
+
+          expose({
+            setValue,
+            value,
+          })
+
+          const n0 = template('<div> </div>', true)() as any
+          const x0 = txt(n0) as any
+          renderEffect(() => setText(x0, value.value))
+          return n0
+        },
+      })
+      customElements.define('my-el-expose', E)
+
+      container.innerHTML = `<my-el-expose></my-el-expose>`
+      const e = container.childNodes[0] as VaporElement & {
+        value: string
+        setValue: MockedFunction<SetValue>
+      }
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
+      expect(e.value).toBe('hello')
+      expect(e.setValue).toBe(fn!)
+      e.setValue('world')
+      expect(e.value).toBe('world')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
+    })
+
+    test('warning when exposing an existing property', () => {
+      const E = defineVaporCustomElement({
+        props: {
+          value: String,
+        },
+        setup(props: any, { expose }: any) {
+          expose({
+            value: 'hello',
+          })
+
+          const n0 = template('<div> </div>', true)() as any
+          const x0 = txt(n0) as any
+          renderEffect(() => setText(x0, props.value))
+          return n0
+        },
+      })
+
+      customElements.define('my-el-expose-two', E)
+      container.innerHTML = `<my-el-expose-two value="world"></my-el-expose-two>`
+
+      expect(
+        `[Vue warn]: Exposed property "value" already exists on custom element.`,
+      ).toHaveBeenWarned()
+    })
+  })
+
+  test('async & nested custom elements', async () => {
+    let fooVal: string | undefined = ''
+    const E = defineVaporCustomElement(
+      defineVaporAsyncComponent(() => {
+        return Promise.resolve({
+          setup() {
+            provide('foo', 'foo')
+            const n0 = template('<div></div>')() as any
+            setInsertionState(n0, null)
+            createSlot('default', null)
+            return n0
+          },
+        })
+      }),
+    )
+
+    const EChild = defineVaporCustomElement({
+      setup() {
+        fooVal = inject('foo')
+        const n0 = template('<div>child</div>')()
+        return n0
+      },
+    })
+    customElements.define('my-el-async-nested-ce', E)
+    customElements.define('slotted-child', EChild)
+    container.innerHTML = `<my-el-async-nested-ce><div><slotted-child></slotted-child></div></my-el-async-nested-ce>`
+
+    await new Promise(r => setTimeout(r))
+    const e = container.childNodes[0] as VaporElement
+    expect(e.shadowRoot!.innerHTML).toBe(`<div><slot></slot><!--slot--></div>`)
+    expect(fooVal).toBe('foo')
+  })
+
+  test('async & multiple levels of nested custom elements', async () => {
+    let fooVal: string | undefined = ''
+    let barVal: string | undefined = ''
+    const E = defineVaporCustomElement(
+      defineVaporAsyncComponent(() => {
+        return Promise.resolve({
+          setup() {
+            provide('foo', 'foo')
+            const n0 = template('<div></div>')() as any
+            setInsertionState(n0, null)
+            createSlot('default', null)
+            return n0
+          },
+        })
+      }),
+    )
+
+    const EChild = defineVaporCustomElement({
+      setup() {
+        provide('bar', 'bar')
+        const n0 = template('<div></div>')() as any
+        setInsertionState(n0, null)
+        createSlot('default', null)
+        return n0
+      },
+    })
+
+    const EChild2 = defineVaporCustomElement({
+      setup() {
+        fooVal = inject('foo')
+        barVal = inject('bar')
+        const n0 = template('<div>child</div>')()
+        return n0
+      },
+    })
+    customElements.define('my-el-async-nested-m-ce', E)
+    customElements.define('slotted-child-m', EChild)
+    customElements.define('slotted-child2-m', EChild2)
+    container.innerHTML =
+      `<my-el-async-nested-m-ce>` +
+      `<div><slotted-child-m>` +
+      `<slotted-child2-m></slotted-child2-m>` +
+      `</slotted-child-m></div>` +
+      `</my-el-async-nested-m-ce>`
+
+    await new Promise(r => setTimeout(r))
+    const e = container.childNodes[0] as VaporElement
+    expect(e.shadowRoot!.innerHTML).toBe(`<div><slot></slot><!--slot--></div>`)
+    expect(fooVal).toBe('foo')
+    expect(barVal).toBe('bar')
+  })
+
+  describe('configureApp', () => {
+    test('should work', () => {
+      const E = defineVaporCustomElement(
+        () => {
+          const msg = inject('msg')
+          const n0 = template('<div> </div>', true)() as any
+          const x0 = txt(n0) as any
+          renderEffect(() => setText(x0, msg as string))
+          return n0
+        },
+        {
+          configureApp(app: any) {
+            app.provide('msg', 'app-injected')
+          },
+        } as any,
+      )
+      customElements.define('my-element-with-app', E)
+
+      container.innerHTML = `<my-element-with-app></my-element-with-app>`
+      const e = container.childNodes[0] as VaporElement
+      expect(e.shadowRoot?.innerHTML).toBe('<div>app-injected</div>')
+    })
+
+    test('work with async component', async () => {
+      const AsyncComp = defineVaporAsyncComponent(() => {
+        return Promise.resolve({
+          setup() {
+            const msg = inject('msg')
+            const n0 = template('<div> </div>', true)() as any
+            const x0 = txt(n0) as any
+            renderEffect(() => setText(x0, msg as string))
+            return n0
+          },
+        } as any)
+      })
+      const E = defineVaporCustomElement(AsyncComp, {
+        configureApp(app: any) {
+          app.provide('msg', 'app-injected')
+        },
+      } as any)
+      customElements.define('my-async-element-with-app', E)
+
+      container.innerHTML = `<my-async-element-with-app></my-async-element-with-app>`
+      const e = container.childNodes[0] as VaporElement
+      await new Promise(r => setTimeout(r))
+      expect(e.shadowRoot?.innerHTML).toBe('<div>app-injected</div>')
+    })
+
+    test('with hmr reload', async () => {
+      const __hmrId = '__hmrWithApp'
+      const def = defineVaporComponent({
+        __hmrId,
+        setup() {
+          const msg = inject('msg')
+          const n0 = template('<div><span> </span></div>')() as any
+          const n1 = child(n0) as any
+          const x1 = txt(n1) as any
+          renderEffect(() => setText(x1, msg as string))
+          return n0
+        },
+      })
+      const E = defineVaporCustomElement(def, {
+        configureApp(app: any) {
+          app.provide('msg', 'app-injected')
+        },
+      } as any)
+      customElements.define('my-element-with-app-hmr', E)
+
+      container.innerHTML = `<my-element-with-app-hmr></my-element-with-app-hmr>`
+      const el = container.childNodes[0] as VaporElement
+      expect(el.shadowRoot?.innerHTML).toBe(
+        `<div><span>app-injected</span></div>`,
+      )
+
+      // hmr
+      __VUE_HMR_RUNTIME__.reload(__hmrId, def as any)
+
+      await nextTick()
+      expect(el.shadowRoot?.innerHTML).toBe(
+        `<div><span>app-injected</span></div>`,
+      )
+    })
+  })
+
+  // #9885
+  // test('avoid double mount when prop is set immediately after mount', () => {
+  //   customElements.define(
+  //     'my-input-dupe',
+  //     defineVaporCustomElement({
+  //       props: {
+  //         value: String,
+  //       },
+  //       render() {
+  //         return 'hello'
+  //       },
+  //     }),
+  //   )
+  //   const container = document.createElement('div')
+  //   document.body.appendChild(container)
+  //   createVaporApp({
+  //     // render() {
+  //     //   return h('div', [
+  //     //     h('my-input-dupe', {
+  //     //       onVnodeMounted(vnode) {
+  //     //         vnode.el!.value = 'fesfes'
+  //     //       },
+  //     //     }),
+  //     //   ])
+  //     // },
+  //     setup() {
+  //       // const n0 = template('<div></div>')() as any
+  //     }
+  //   }).mount(container)
+  //   expect(container.children[0].children[0].shadowRoot?.innerHTML).toBe(
+  //     'hello',
+  //   )
+  // })
+
+  test('Props can be casted when mounting custom elements in component rendering functions', async () => {
+    const E = defineVaporCustomElement(
+      defineVaporAsyncComponent(() =>
+        Promise.resolve({
+          props: ['fooValue'],
+          setup(props: any) {
+            expect(props.fooValue).toBe('fooValue')
+            const n0 = template('<div> </div>', true)() as any
+            const x0 = txt(n0) as any
+            renderEffect(() => setText(x0, props.fooValue))
+            return n0
+          },
+        }),
+      ),
+    )
+    customElements.define('my-el-async-4', E)
+    const R = defineVaporComponent({
+      setup() {
+        const fooValue = ref('fooValue')
+        const n0 = template('<div></div>')() as any
+        setInsertionState(n0, null)
+        createPlainElement('my-el-async-4', {
+          fooValue: () => fooValue.value,
+        })
+        return n0
+      },
+    })
+
+    const app = createVaporApp(R)
+    app.mount(container)
+    await new Promise(r => setTimeout(r))
+    const e = container.querySelector('my-el-async-4') as VaporElement
+    expect(e.shadowRoot!.innerHTML).toBe(`<div>fooValue</div>`)
+    app.unmount()
+  })
+
+  test('delete prop on attr removal', async () => {
+    const E = defineVaporCustomElement({
+      props: {
+        boo: {
+          type: Boolean,
+        },
+      },
+      setup(props: any) {
+        const n0 = template(' ')() as any
+        renderEffect(() => setText(n0, `${props.boo},${typeof props.boo}`))
+        return n0
+      },
+    })
+    customElements.define('el-attr-removal', E)
+    container.innerHTML = '<el-attr-removal boo>'
+    const e = container.childNodes[0] as VaporElement
+    expect(e.shadowRoot!.innerHTML).toBe(`true,boolean`)
+    e.removeAttribute('boo')
+    await nextTick()
+    expect(e.shadowRoot!.innerHTML).toBe(`false,boolean`)
+  })
+
+  test('hyphenated attr removal', async () => {
+    const E = defineVaporCustomElement({
+      props: {
+        fooBar: {
+          type: Boolean,
+        },
+      },
+      setup(props: any) {
+        const n0 = template(' ')() as any
+        renderEffect(() => setText(n0, toDisplayString(props.fooBar)))
+        return n0
+      },
+    })
+    customElements.define('el-hyphenated-attr-removal', E)
+    const toggle = ref(true)
+    const { container } = render('el-hyphenated-attr-removal', {
+      'foo-bar': () => (toggle.value ? '' : null),
+    })
+    const el = container.children[0]
+    expect(el.hasAttribute('foo-bar')).toBe(true)
+    expect((el as any).outerHTML).toBe(
+      `<el-hyphenated-attr-removal foo-bar=""></el-hyphenated-attr-removal>`,
+    )
+
+    toggle.value = false
+    await nextTick()
+    expect(el.hasAttribute('foo-bar')).toBe(false)
+    expect((el as any).outerHTML).toBe(
+      `<el-hyphenated-attr-removal></el-hyphenated-attr-removal>`,
+    )
+  })
+
+  test('no unexpected mutation of the 1st argument', () => {
+    const Foo = {
+      __vapor: true,
+      name: 'Foo',
+    }
+
+    defineVaporCustomElement(Foo, { shadowRoot: false } as any)
+
+    expect(Foo).toEqual({
+      __vapor: true,
+      name: 'Foo',
+    })
+  })
+})
index 37e77e8831ec9f51168772ba4a7ef71bfd76abcf..234c79fac8c6c06395f7e39c19d29b634a390fe7 100644 (file)
@@ -36,14 +36,16 @@ const mountApp: AppMountFn<ParentNode> = (app, container) => {
     container.textContent = ''
   }
 
-  const instance = createComponent(
-    app._component,
-    app._props as RawProps,
-    null,
-    false,
-    false,
-    app._context,
-  )
+  const instance =
+    (app._ceComponent as VaporComponentInstance) ||
+    createComponent(
+      app._component,
+      app._props as RawProps,
+      null,
+      false,
+      false,
+      app._context,
+    )
   mountComponent(instance, container)
   flushOnAppMount()
 
@@ -57,14 +59,16 @@ const hydrateApp: AppMountFn<ParentNode> = (app, container) => {
 
   let instance: VaporComponentInstance
   withHydration(container, () => {
-    instance = createComponent(
-      app._component,
-      app._props as RawProps,
-      null,
-      false,
-      false,
-      app._context,
-    )
+    instance =
+      (app._ceComponent as VaporComponentInstance) ||
+      createComponent(
+        app._component,
+        app._props as RawProps,
+        null,
+        false,
+        false,
+        app._context,
+      )
     mountComponent(instance, container)
     flushOnAppMount()
   })
index fcc706888a94f0c188f45beed687593421861bce..25b99c987f30e644a8ce68456bca1235b54cbe6c 100644 (file)
@@ -208,10 +208,5 @@ function createInnerComp(
   // @ts-expect-error
   frag && frag.setRef && frag.setRef(instance)
 
-  // TODO custom element
-  // pass the custom element callback on to the inner comp
-  // and remove it from the async wrapper
-  // i.ce = ce
-  // delete parent.ce
   return instance
 }
diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts
new file mode 100644 (file)
index 0000000..f896b49
--- /dev/null
@@ -0,0 +1,210 @@
+import { extend, isPlainObject } from '@vue/shared'
+import {
+  createComponent,
+  createVaporApp,
+  createVaporSSRApp,
+  defineVaporComponent,
+  isFragment,
+} from '.'
+import {
+  type CreateAppFunction,
+  type CustomElementOptions,
+  VueElementBase,
+  warn,
+} from '@vue/runtime-dom'
+import type {
+  ObjectVaporComponent,
+  VaporComponent,
+  VaporComponentInstance,
+} from './component'
+import type { Block } from './block'
+import { withHydration } from './dom/hydration'
+
+export type VaporElementConstructor<P = {}> = {
+  new (initialProps?: Record<string, any>): VaporElement & P
+}
+
+// TODO type inference
+
+/*@__NO_SIDE_EFFECTS__*/
+export function defineVaporCustomElement(
+  options: any,
+  extraOptions?: Omit<ObjectVaporComponent, 'setup'>,
+  /**
+   * @internal
+   */
+  _createApp?: CreateAppFunction<ParentNode, VaporComponent>,
+): VaporElementConstructor {
+  let Comp = defineVaporComponent(options, extraOptions)
+  if (isPlainObject(Comp)) Comp = extend({}, Comp, extraOptions)
+  class VaporCustomElement extends VaporElement {
+    static def = Comp
+    constructor(initialProps?: Record<string, any>) {
+      super(Comp, initialProps, _createApp)
+    }
+  }
+
+  return VaporCustomElement
+}
+
+/*@__NO_SIDE_EFFECTS__*/
+export const defineVaporSSRCustomElement = ((
+  options: any,
+  extraOptions?: Omit<ObjectVaporComponent, 'setup'>,
+) => {
+  return defineVaporCustomElement(options, extraOptions, createVaporSSRApp)
+}) as typeof defineVaporCustomElement
+
+type VaporInnerComponentDef = VaporComponent & CustomElementOptions
+
+export class VaporElement extends VueElementBase<
+  ParentNode,
+  VaporComponent,
+  VaporInnerComponentDef
+> {
+  constructor(
+    def: VaporInnerComponentDef,
+    props: Record<string, any> | undefined = {},
+    createAppFn: CreateAppFunction<ParentNode, VaporComponent> = createVaporApp,
+  ) {
+    super(def, props, createAppFn)
+  }
+
+  protected _needsHydration(): boolean {
+    if (this.shadowRoot && this._createApp !== createVaporApp) {
+      return true
+    } else {
+      if (__DEV__ && this.shadowRoot) {
+        warn(
+          `Custom element has pre-rendered declarative shadow root but is not ` +
+            `defined as hydratable. Use \`defineVaporSSRCustomElement\`.`,
+        )
+      }
+    }
+    return false
+  }
+  protected _mount(def: VaporInnerComponentDef): void {
+    if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) {
+      def.name = 'VaporElement'
+    }
+
+    this._app = this._createApp(this._def)
+    this._inheritParentContext()
+    if (this._def.configureApp) {
+      this._def.configureApp(this._app)
+    }
+
+    // create component in hydration context
+    if (this.shadowRoot && this._createApp === createVaporSSRApp) {
+      withHydration(this._root, this._createComponent.bind(this))
+    } else {
+      this._createComponent()
+    }
+
+    this._app!.mount(this._root)
+
+    // Render slots immediately after mount for shadowRoot: false
+    // This ensures correct lifecycle order for nested custom elements
+    if (!this.shadowRoot) {
+      this._renderSlots()
+    }
+  }
+
+  protected _update(): void {
+    if (!this._app) return
+    // update component by re-running all its render effects
+    const renderEffects = (this._instance! as VaporComponentInstance)
+      .renderEffects
+    if (renderEffects) renderEffects.forEach(e => e.run())
+  }
+
+  protected _unmount(): void {
+    if (__TEST__) {
+      try {
+        this._app!.unmount()
+      } catch (error) {
+        // In test environment, ignore errors caused by accessing Node
+        // after the test environment has been torn down
+        if (
+          error instanceof ReferenceError &&
+          error.message.includes('Node is not defined')
+        ) {
+          // Ignore this error in tests
+        } else {
+          throw error
+        }
+      }
+    } else {
+      this._app!.unmount()
+    }
+    if (this._instance && this._instance.ce) {
+      this._instance.ce = undefined
+    }
+    this._app = this._instance = null
+  }
+
+  /**
+   * Only called when shadowRoot is false
+   */
+  protected _updateSlotNodes(replacements: Map<Node, Node[]>): void {
+    this._updateFragmentNodes(
+      (this._instance! as VaporComponentInstance).block,
+      replacements,
+    )
+  }
+
+  /**
+   * Replace slot nodes with their replace content
+   * @internal
+   */
+  private _updateFragmentNodes(
+    block: Block,
+    replacements: Map<Node, Node[]>,
+  ): void {
+    if (Array.isArray(block)) {
+      block.forEach(item => this._updateFragmentNodes(item, replacements))
+      return
+    }
+
+    if (!isFragment(block)) return
+    const { nodes } = block
+    if (Array.isArray(nodes)) {
+      const newNodes: Block[] = []
+      for (const node of nodes) {
+        if (node instanceof HTMLSlotElement) {
+          newNodes.push(...replacements.get(node)!)
+        } else {
+          this._updateFragmentNodes(node, replacements)
+          newNodes.push(node)
+        }
+      }
+      block.nodes = newNodes
+    } else if (nodes instanceof HTMLSlotElement) {
+      block.nodes = replacements.get(nodes)!
+    } else {
+      this._updateFragmentNodes(nodes, replacements)
+    }
+  }
+
+  private _createComponent() {
+    this._def.ce = instance => {
+      this._app!._ceComponent = this._instance = instance
+      // For shadowRoot: false, _renderSlots is called synchronously after mount
+      // in _mount() to ensure correct lifecycle order
+      if (!this.shadowRoot) {
+        // Still set updated hooks for subsequent updates
+        this._instance!.u = [this._renderSlots.bind(this)]
+      }
+      this._processInstance()
+    }
+
+    createComponent(
+      this._def,
+      this._props,
+      undefined,
+      undefined,
+      undefined,
+      this._app!._context,
+    )
+  }
+}
index d083d7404cbdf8f197882e4d37b93140bc081fc7..ec4f72db2e4a27bf98455268658d53d1bd3caa48 100644 (file)
@@ -97,6 +97,7 @@ import {
   resetInsertionState,
 } from './insertionState'
 import { DynamicFragment } from './fragment'
+import type { VaporElement } from './apiDefineVaporCustomElement'
 
 export { currentInstance } from '@vue/runtime-dom'
 
@@ -130,6 +131,10 @@ export interface ObjectVaporComponent
 
   name?: string
   vapor?: boolean
+  /**
+   * @internal custom element interception hook
+   */
+  ce?: (instance: VaporComponentInstance) => void
 }
 
 interface SharedInternalOptions {
@@ -490,8 +495,15 @@ export class VaporComponentInstance implements GenericComponentInstance {
   // for suspense
   suspense: SuspenseBoundary | null
 
+  // for HMR and vapor custom element
+  // all render effects associated with this instance
+  renderEffects?: RenderEffect[]
+
   hasFallthrough: boolean
 
+  // for keep-alive
+  shapeFlag?: number
+
   // lifecycle hooks
   isMounted: boolean
   isUnmounted: boolean
@@ -518,12 +530,10 @@ export class VaporComponentInstance implements GenericComponentInstance {
   devtoolsRawSetupState?: any
   hmrRerender?: () => void
   hmrReload?: (newComp: VaporComponent) => void
-  renderEffects?: RenderEffect[]
   parentTeleport?: TeleportFragment | null
   propsOptions?: NormalizedPropsOptions
   emitsOptions?: ObjectEmitsOptions | null
   isSingleRoot?: boolean
-  shapeFlag?: number
 
   constructor(
     comp: VaporComponent,
@@ -589,6 +599,11 @@ export class VaporComponentInstance implements GenericComponentInstance {
         ? new Proxy(rawSlots, dynamicSlotsProxyHandlers)
         : rawSlots
       : EMPTY_OBJ
+
+    // apply custom element special handling
+    if (comp.ce) {
+      comp.ce(this)
+    }
   }
 
   /**
@@ -630,6 +645,16 @@ export function createComponentWithFallback(
     )
   }
 
+  return createPlainElement(comp, rawProps, rawSlots, isSingleRoot, once)
+}
+
+export function createPlainElement(
+  comp: string,
+  rawProps?: LooseRawProps | null,
+  rawSlots?: LooseRawSlots | null,
+  isSingleRoot?: boolean,
+  once?: boolean,
+): HTMLElement {
   const _insertionParent = insertionParent
   const _insertionAnchor = insertionAnchor
   const _isLastInsertion = isLastInsertion
@@ -702,6 +727,17 @@ export function mountComponent(
     return
   }
 
+  // custom element style injection
+  const { root, type } = instance as GenericComponentInstance
+  if (
+    root &&
+    root.ce &&
+    // @ts-expect-error _def is private
+    (root.ce as VaporElement)._def.shadowRoot !== false
+  ) {
+    root.ce!._injectChildStyle(type)
+  }
+
   if (__DEV__) {
     startMeasure(instance, `mount`)
   }
index 6832bd9103c6348021933c83ff9abe079d8f7bd8..c10008b3af20f7d0b30569a8de6029cc78a5fb28 100644 (file)
@@ -97,7 +97,7 @@ export function getPropsProxyHandlers(
         return resolvePropValue(
           propsOptions!,
           key,
-          rawProps[rawKey](),
+          resolveSource(rawProps[rawKey]),
           instance,
           resolveDefault,
         )
@@ -217,10 +217,11 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
     }
   }
   if (hasOwn(rawProps, key)) {
+    const value = resolveSource(rawProps[key])
     if (merged) {
-      merged.push(rawProps[key]())
+      merged.push(value)
     } else {
-      return rawProps[key]()
+      return value
     }
   }
   if (merged && merged.length) {
@@ -330,7 +331,7 @@ export function resolveDynamicProps(props: RawProps): Record<string, unknown> {
   const mergedRawProps: Record<string, any> = {}
   for (const key in props) {
     if (key !== '$') {
-      mergedRawProps[key] = props[key]()
+      mergedRawProps[key] = resolveSource(props[key])
     }
   }
   if (props.$) {
index b2a3ff5fb97bfba8eaad6b4d46fb74e4f3528278..01b0be5d4dd69e836313dd67f045d9761e357b35 100644 (file)
@@ -1,7 +1,13 @@
 import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared'
 import { type Block, type BlockFn, insert, setScopeId } from './block'
 import { rawPropsProxyHandlers } from './componentProps'
-import { currentInstance, isRef, setCurrentInstance } from '@vue/runtime-dom'
+import {
+  type GenericComponentInstance,
+  currentInstance,
+  isAsyncWrapper,
+  isRef,
+  setCurrentInstance,
+} from '@vue/runtime-dom'
 import type { LooseRawProps, VaporComponentInstance } from './component'
 import { renderEffect } from './renderEffect'
 import {
@@ -16,6 +22,8 @@ import {
   locateHydrationNode,
 } from './dom/hydration'
 import { DynamicFragment, type VaporFragment } from './fragment'
+import { createElement } from './dom/node'
+import { setDynamicProps } from './dom/prop'
 
 /**
  * Current slot scopeIds for vdom interop
@@ -184,7 +192,30 @@ export function createSlot(
     }
 
     const renderSlot = () => {
-      const slot = getSlot(rawSlots, isFunction(name) ? name() : name)
+      const slotName = isFunction(name) ? name() : name
+
+      // in custom element mode, render <slot/> as actual slot outlets
+      // because in shadowRoot: false mode the slot element gets
+      // replaced by injected content
+      if (
+        (instance as GenericComponentInstance).ce ||
+        (instance.parent &&
+          isAsyncWrapper(instance.parent) &&
+          instance.parent.ce)
+      ) {
+        const el = createElement('slot')
+        renderEffect(() => {
+          setDynamicProps(el, [
+            slotProps,
+            slotName !== 'default' ? { name: slotName } : {},
+          ])
+        })
+        if (fallback) insert(fallback(), el)
+        fragment.nodes = el
+        return
+      }
+
+      const slot = getSlot(rawSlots, slotName)
       if (slot) {
         fragment.fallback = fallback
         // Create and cache bound version of the slot to make it stable
index 746798f71bf3244982d191397b92962fb4ac87b9..3c061bc0b14e7a4b04ed52af5892c81e1af61633 100644 (file)
@@ -1,7 +1,9 @@
 import {
+  type GenericComponentInstance,
   MismatchTypes,
   type TeleportProps,
   type TeleportTargetElement,
+  currentInstance,
   isMismatchAllowed,
   isTeleportDeferred,
   isTeleportDisabled,
@@ -54,11 +56,13 @@ export class TeleportFragment extends VaporFragment {
   placeholder?: Node
   mountContainer?: ParentNode | null
   mountAnchor?: Node | null
+  parentComponent: GenericComponentInstance
 
   constructor(props: LooseRawProps, slots: LooseRawSlots) {
     super([])
     this.rawProps = props
     this.rawSlots = slots
+    this.parentComponent = currentInstance as GenericComponentInstance
     this.anchor = isHydrating
       ? undefined
       : __DEV__
@@ -149,6 +153,14 @@ export class TeleportFragment extends VaporFragment {
           insert((this.targetAnchor = createTextNode('')), target)
         }
 
+        // track CE teleport targets
+        if (this.parentComponent && this.parentComponent.isCE) {
+          ;(
+            this.parentComponent.ce!._teleportTargets ||
+            (this.parentComponent.ce!._teleportTargets = new Set())
+          ).add(target)
+        }
+
         mount(target, this.targetAnchor!)
       } else if (__DEV__) {
         warn(
index 6d41a7c1bce2b3406352016bf698e4e9e24a41c4..c7f9631509f22dfdcb352d98d7df1987f36a3bf7 100644 (file)
@@ -1,5 +1,6 @@
 import {
   type NormalizedStyle,
+  camelize,
   canSetValueDirectly,
   includeBooleanAttr,
   isArray,
@@ -38,6 +39,7 @@ import {
 } from '../component'
 import { isHydrating, logMismatchError } from './hydration'
 import type { Block } from '../block'
+import type { VaporElement } from '../apiDefineVaporCustomElement'
 
 type TargetElement = Element & {
   $root?: true
@@ -112,6 +114,7 @@ export function setDOMProp(
   key: string,
   value: any,
   forceHydrate: boolean = false,
+  attrName?: string,
 ): void {
   if (!isApplyingFallthroughProps && el.$root && hasFallthroughKey(key)) {
     return
@@ -163,7 +166,7 @@ export function setDOMProp(
       )
     }
   }
-  needRemove && el.removeAttribute(key)
+  needRemove && el.removeAttribute(attrName || key)
 }
 
 export function setClass(
@@ -484,6 +487,12 @@ export function setDynamicProp(
     } else {
       setDOMProp(el, key, value, forceHydrate)
     }
+  } else if (
+    // custom elements
+    (el as VaporElement)._isVueCE &&
+    (/[A-Z]/.test(key) || !isString(value))
+  ) {
+    setDOMProp(el, camelize(key), value, forceHydrate, key)
   } else {
     setAttr(el, key, value, isSVG)
   }
@@ -503,12 +512,12 @@ export function optimizePropertyLookup(): void {
   proto.$key = undefined
   proto.$fc = proto.$evtclick = undefined
   proto.$root = false
-  proto.$html =
-    proto.$txt =
-    proto.$cls =
-    proto.$sty =
-    (Text.prototype as any).$txt =
-      ''
+  proto.$html = proto.$cls = proto.$sty = ''
+  // Initialize $txt to undefined instead of empty string to ensure setText()
+  // properly updates the text node even when the value is empty string.
+  // This prevents issues where setText(node, '') would be skipped because
+  // $txt === '' would return true, leaving the original nodeValue unchanged.
+  ;(Text.prototype as any).$txt = undefined
 }
 
 function classHasMismatch(
index d669fba909ebe6d73d6c778efa7c8ea59f3ab2d8..c20c62490628d35037032f61f65db48ed3696c68 100644 (file)
@@ -6,6 +6,10 @@ export { vaporInteropPlugin } from './vdomInterop'
 export type { VaporDirective } from './directives/custom'
 export { VaporTeleportImpl as VaporTeleport } from './components/Teleport'
 export { VaporKeepAliveImpl as VaporKeepAlive } from './components/KeepAlive'
+export {
+  defineVaporCustomElement,
+  defineVaporSSRCustomElement,
+} from './apiDefineVaporCustomElement'
 
 // compiler-use only
 export { insert, prepend, remove } from './block'
@@ -13,6 +17,7 @@ export { setInsertionState } from './insertionState'
 export {
   createComponent,
   createComponentWithFallback,
+  createPlainElement,
   isVaporComponent,
 } from './component'
 export { renderEffect } from './renderEffect'
index 3c937c0ed5866aabec275462a70d93dab5040d4e..e36ac4ba4586606a6858b4c6d40f8bff1e3c30d0 100644 (file)
@@ -41,7 +41,9 @@ export class RenderEffect extends ReactiveEffect {
         this.onTrigger = instance.rtg
           ? e => invokeArrayFns(instance.rtg!, e)
           : void 0
+      }
 
+      if (__DEV__ || instance.type.ce) {
         // register effect for stopping them during HMR rerender
         ;(instance.renderEffects || (instance.renderEffects = [])).push(this)
       }
diff --git a/packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts b/packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts
new file mode 100644 (file)
index 0000000..c50bff6
--- /dev/null
@@ -0,0 +1,128 @@
+import path from 'node:path'
+import fs from 'node:fs'
+import { setupPuppeteer } from './e2eUtils'
+
+const { page, click, text } = setupPuppeteer()
+
+let vaporDataUrl: string
+
+beforeAll(() => {
+  // Read the vapor ESM module once
+  const vaporPath = path.resolve(
+    __dirname,
+    '../../dist/vue.runtime-with-vapor.esm-browser.js',
+  )
+  const vaporCode = fs.readFileSync(vaporPath, 'utf-8')
+
+  // Create a data URL for the ESM module
+  vaporDataUrl = `data:text/javascript;base64,${Buffer.from(vaporCode).toString('base64')}`
+})
+
+async function loadVaporModule() {
+  // Load module and expose to window
+  await page().addScriptTag({
+    content: `
+      import('${vaporDataUrl}').then(module => {
+        window.VueVapor = module;
+      });
+    `,
+    type: 'module',
+  })
+
+  // Wait for VueVapor to be available
+  await page().waitForFunction(
+    () => typeof (window as any).VueVapor !== 'undefined',
+    { timeout: 10000 },
+  )
+}
+
+async function setContent(html: string) {
+  // For SSR content with declarative shadow DOM, we need to use setContent
+  // which causes the browser to parse the HTML properly
+  await page().setContent(`
+    <!DOCTYPE html>
+    <html>
+      <body>
+        <div id="app">${html}</div>
+      </body>
+    </html>
+  `)
+
+  // load the vapor module after setting content
+  await loadVaporModule()
+}
+
+// this must be tested in actual Chrome because jsdom does not support
+// declarative shadow DOM
+test('ssr vapor custom element hydration', async () => {
+  await setContent(
+    `<my-element><template shadowrootmode="open"><button>1</button></template></my-element><my-element-async><template shadowrootmode="open"><button>1</button></template></my-element-async>`,
+  )
+
+  await page().evaluate(() => {
+    const {
+      ref,
+      defineVaporSSRCustomElement,
+      defineVaporAsyncComponent,
+      onMounted,
+      useHost,
+      template,
+      child,
+      setText,
+      renderEffect,
+      delegateEvents,
+    } = (window as any).VueVapor
+
+    delegateEvents('click')
+
+    const def = {
+      setup() {
+        const count = ref(1)
+        const el = useHost()
+        onMounted(() => (el.style.border = '1px solid red'))
+
+        const n0 = template('<button> </button>')()
+        const x0 = child(n0)
+        n0.$evtclick = () => count.value++
+        renderEffect(() => setText(x0, count.value))
+        return n0
+      },
+    }
+
+    customElements.define('my-element', defineVaporSSRCustomElement(def))
+    customElements.define(
+      'my-element-async',
+      defineVaporSSRCustomElement(
+        defineVaporAsyncComponent(
+          () =>
+            new Promise(r => {
+              ;(window as any).resolve = () => r(def)
+            }),
+        ),
+      ),
+    )
+  })
+
+  function getColor() {
+    return page().evaluate(() => {
+      return [
+        (document.querySelector('my-element') as any).style.border,
+        (document.querySelector('my-element-async') as any).style.border,
+      ]
+    })
+  }
+
+  expect(await getColor()).toMatchObject(['1px solid red', ''])
+  await page().evaluate(() => (window as any).resolve()) // exposed by test
+  expect(await getColor()).toMatchObject(['1px solid red', '1px solid red'])
+
+  async function assertInteraction(el: string) {
+    const selector = `${el} >>> button`
+    expect(await text(selector)).toBe('1')
+    await click(selector)
+    expect(await text(selector)).toBe('2')
+  }
+
+  await assertInteraction('my-element')
+  await assertInteraction('my-element-async')
+})
index c875f1bee69c388b60f0858cb227dbdb80b38e39..c39286d3d1274e8efdf749121e907e017a8e368f 100644 (file)
@@ -78,49 +78,6 @@ test('ssr custom element hydration', async () => {
   await assertInteraction('my-element-async')
 })
 
-test('work with Teleport (shadowRoot: false)', async () => {
-  await setContent(
-    `<div id='test'></div><my-p><my-y><span>default</span></my-y></my-p>`,
-  )
-
-  await page().evaluate(() => {
-    const { h, defineSSRCustomElement, Teleport, renderSlot } = (window as any)
-      .Vue
-    const Y = defineSSRCustomElement(
-      {
-        render() {
-          return h(
-            Teleport,
-            { to: '#test' },
-            {
-              default: () => [renderSlot(this.$slots, 'default')],
-            },
-          )
-        },
-      },
-      { shadowRoot: false },
-    )
-    customElements.define('my-y', Y)
-    const P = defineSSRCustomElement(
-      {
-        render() {
-          return renderSlot(this.$slots, 'default')
-        },
-      },
-      { shadowRoot: false },
-    )
-    customElements.define('my-p', P)
-  })
-
-  function getInnerHTML() {
-    return page().evaluate(() => {
-      return (document.querySelector('#test') as any).innerHTML
-    })
-  }
-
-  expect(await getInnerHTML()).toBe('<span>default</span>')
-})
-
 // #11641
 test('pass key to custom element', async () => {
   const messages: string[] = []