diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap index 8d9df60dfa1..39cf802be0a 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap @@ -323,6 +323,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'; diff --git a/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts b/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts index 66768508f67..398eac518ff 100644 --- a/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts @@ -1037,4 +1037,15 @@ describe('compiler: element transform', () => { expect(code).toMatchSnapshot() expect(code).contain('return null') }) + + test('custom element', () => { + const { code } = compileWithElementTransform( + '', + { + isCustomElement: tag => tag === 'my-custom-element', + }, + ) + expect(code).toMatchSnapshot() + expect(code).toContain('createPlainElement') + }) }) diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index d4bfeefdb61..c2da7a3f22e 100644 --- a/packages/compiler-vapor/src/generators/component.ts +++ b/packages/compiler-vapor/src/generators/component.ts @@ -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'), diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts index 15cf85ae10e..a8dcdb430cb 100644 --- a/packages/compiler-vapor/src/ir/index.ts +++ b/packages/compiler-vapor/src/ir/index.ts @@ -202,6 +202,7 @@ export interface CreateComponentIRNode extends BaseIRNode { root: boolean once: boolean dynamic?: SimpleExpressionNode + isCustomElement: boolean parent?: number anchor?: number append?: boolean diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index facffadff10..c9b5a9d7a3f 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -57,7 +57,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, @@ -77,9 +82,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( @@ -88,6 +94,7 @@ export const transformElement: NodeTransform = (node, context) => { singleRoot, context, isDynamicComponent, + isCustomElement, ) } else { transformNativeElement( @@ -107,6 +114,7 @@ function transformComponentElement( singleRoot: boolean, context: TransformContext, isDynamicComponent: boolean, + isCustomElement: boolean, ) { const dynamicComponent = isDynamicComponent ? resolveDynamicComponent(node) @@ -115,7 +123,7 @@ function transformComponentElement( let { tag } = node let asset = true - if (!dynamicComponent) { + if (!dynamicComponent && !isCustomElement) { const fromSetup = resolveSetupReference(tag, context) if (fromSetup) { tag = fromSetup @@ -160,6 +168,7 @@ function transformComponentElement( slots: [...context.slots], once: context.inVOnce, dynamic: dynamicComponent, + isCustomElement, } context.slots = [] } diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index f651d76d799..e3a63bf846d 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -111,6 +111,11 @@ export interface App { */ _ceVNode?: VNode + /** + * @internal vapor custom element instance + */ + _ceComponent?: GenericComponentInstance | null + /** * v2 compat only */ diff --git a/packages/runtime-core/src/componentCurrentInstance.ts b/packages/runtime-core/src/componentCurrentInstance.ts index 7ac52a2e997..727958fd84c 100644 --- a/packages/runtime-core/src/componentCurrentInstance.ts +++ b/packages/runtime-core/src/componentCurrentInstance.ts @@ -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 = ( + 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] } +} diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index c8029950e2d..8a0ed8746e2 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/packages/runtime-core/src/hmr.ts @@ -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) } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index a05f8985e1a..67ff4f0e913 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -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 diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 85d37bc117e..596e03112ba 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -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 - private _connected = false - private _resolved = false - private _patching = false - private _dirty = false - private _numberProps: Record | null = null - private _styleChildren = new WeakSet() - private _pendingResolve: Promise | undefined - private _parent: VueElement | undefined + protected _connected = false + protected _resolved = false + protected _numberProps: Record | null = null + protected _styleChildren: WeakSet = new WeakSet() + protected _pendingResolve: Promise | undefined + protected _parent: VueElementBase | undefined + protected _patching = false + protected _dirty = false + + protected _def: Def + protected _props: Record + protected _createApp: CreateAppFunction + /** * dev only */ - private _styles?: HTMLStyleElement[] + protected _styles?: HTMLStyleElement[] /** * dev only */ - private _childStyles?: Map - private _ob?: MutationObserver | null = null - private _slots?: Record + protected _childStyles?: Map + protected _ob?: MutationObserver | null = null + protected _slots?: Record + + /** + * 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): 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 = {}, - private _createApp: CreateAppFunction = createApp, + def: Def, + props: Record | undefined = {}, + createAppFn: CreateAppFunction, ) { 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 { - 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 = 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 | undefined = {}, + createAppFn: CreateAppFunction = 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): void { + // do nothing + } + + private _createVNode(): VNode { + 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.`, ) diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts index c4518b00291..0d765470192 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -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 index 00000000000..c109be3738e --- /dev/null +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -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('
', 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 = `` + const e = container.childNodes[0] as VaporElement + expect(e).toBeInstanceOf(E) + expect(e._instance).toBeTruthy() + expect(e.shadowRoot!.innerHTML).toBe(`
hello
`) + }) + + 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(`
inline
`) + }) + + test('should unmount on remove', async () => { + container.innerHTML = `` + 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('', 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('
', 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 = `
` + 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('
hello
') + }) + + test('remove then insert again', async () => { + container.innerHTML = `` + 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('
hello
') + }) + }) + + describe('props', () => { + const E = defineVaporCustomElement({ + props: { + foo: [String, null], + bar: Object, + bazQux: null, + value: null, + }, + setup(props: any) { + const n0 = template('
', true)() as any + const x0 = txt(n0) as any + const n1 = template('
', 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 = `` + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe('
hello
bye
') + + // change attr + e.setAttribute('foo', 'changed') + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
changed
bye
') + + e.setAttribute('baz-qux', 'changed') + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + '
changed
changed
', + ) + }) + + 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('
one
two
') + + // reflect + // should reflect primitive value + expect(e.getAttribute('foo')).toBe('one') + // should not reflect rich data + expect(e.hasAttribute('bar')).toBe(false) + + e.foo = 'three' + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
three
two
') + expect(e.getAttribute('foo')).toBe('three') + + e.foo = null + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
two
') + expect(e.hasAttribute('foo')).toBe(false) + + e.foo = undefined + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
two
') + expect(e.hasAttribute('foo')).toBe(false) + expect(e.foo).toBe(undefined) + + e.bazQux = 'four' + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
four
') + 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('
foo1
bar1
') + + // change attr then property + e.setAttribute('foo', 'foo2') + e.bar = { x: 'bar2' } + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
foo2
bar2
') + 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('
foo3
bar3
') + 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('') + }) + + 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 = `` + 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('
', 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('
foo
', true)() + }, + }) + customElements.define('my-element-noprops', E) + const el = document.createElement('my-element-noprops') + container.appendChild(el) + await nextTick() + expect(el.shadowRoot!.innerHTML).toMatchInlineSnapshot('"
foo
"') + }) + + 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 = `` + 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 = `` + 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 = `` + 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( + '
', + 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( + '
1 is numbertrue is boolean
', + ) + }) + }) + + describe('attrs', () => { + const E = defineVaporCustomElement({ + setup(_: any, { attrs }: any) { + const n0 = template('
')() 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 = `` + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe('
hello
') + + e.setAttribute('foo', 'changed') + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
changed
') + }) + + 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('
') + }) + + // 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('', 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('
', 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 = `` + 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 = `` + 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(res => res(CompDef as any)) + const E = defineVaporCustomElement(defineVaporAsyncComponent(() => p)) + customElements.define('my-async-el-emits', E) + container.innerHTML = `` + 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(res => res(CompDef as any)), + ), + ) + customElements.define('my-async-el-props-emits', E) + container.innerHTML = `` + 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 = `` + 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('
fallback
') + const t1 = template('
') + 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 = `hi` + 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( + `
` + + `
fallback
` + + `
` + + `
` + + `` + + `
`, + ) + }) + + test('render slot props', async () => { + const foo = ref('foo') + const E = defineVaporCustomElement({ + setup() { + const n0 = template('
')() as any + setInsertionState(n0, null) + createSlot('default', { class: () => foo.value }) + return [n0] + }, + }) + customElements.define('my-el-slot-props', E) + container.innerHTML = `hi` + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe( + `
`, + ) + + foo.value = 'bar' + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + `
`, + ) + }) + }) + + describe('provide/inject', () => { + const Consumer = defineVaporCustomElement({ + setup() { + const foo = inject('foo')! + const n0 = template('
', 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 = `` + const provider = container.childNodes[0] as VaporElement + const consumer = provider.shadowRoot!.childNodes[0] as VaporElement + + expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) + + foo.value = 'changed!' + await nextTick() + expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) + }) + + 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 = `` + const provider = container.childNodes[0] + const consumer = provider.childNodes[0] as VaporElement + expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) + + foo.value = 'changed!' + await nextTick() + expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) + }) + + 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('fooA')! + const fooB = inject('fooB')! + const n0 = template('
', 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 = `` + 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(`
FooA! FooB!
`) + + fooA.value = 'changedA!' + fooB.value = 'changedB!' + await nextTick() + expect(consumer.shadowRoot!.innerHTML).toBe( + `
changedA! changedB!
`, + ) + }) + + 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('shared'), + inject('outer'), + inject('inner'), + ) + + const n0 = template('
', 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('shared'), + inject('outer'), + inject('inner'), + ) + const n0 = template('
', 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('shared'), + inject('outer'), + inject('inner'), + ) + const n0 = template('
', 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 = + '' + + '' + + '' + + '' + + '' + + const outer = container.childNodes[0] as VaporElement + expect(outer.shadowRoot!.innerHTML).toBe( + '
', + ) + + 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('
hello
', true)() + }, + } as any) + const Foo = defineVaporCustomElement(def) + customElements.define('my-el-with-styles', Foo) + container.innerHTML = `` + 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 = `` + 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 = `` + 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('
hello
', true)() + }, + }, + { nonce: 'xxx' } as any, + ) + customElements.define('my-el-with-nonce', Foo) + container.innerHTML = `` + 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('
', true)() as any + const x0 = txt(n0) as any + renderEffect(() => setText(x0, props.msg)) + return n0 + }, + }) + }), + ) + customElements.define('my-el-async', E) + container.innerHTML = + `` + + `` + + await new Promise(r => setTimeout(r)) + + // loader should be called only once + expect(loaderSpy).toHaveBeenCalledTimes(1) + + const e1 = container.childNodes[0] as VaporElement + const e2 = container.childNodes[1] as VaporElement + + // should inject styles + expect(e1.shadowRoot!.innerHTML).toBe( + `
hello
`, + ) + expect(e2.shadowRoot!.innerHTML).toBe( + `
world
`, + ) + + // attr + e1.setAttribute('msg', 'attr') + await nextTick() + expect((e1 as any).msg).toBe('attr') + expect(e1.shadowRoot!.innerHTML).toBe( + `
attr
`, + ) + + // props + expect(`msg` in e1).toBe(true) + ;(e1 as any).msg = 'prop' + expect(e1.getAttribute('msg')).toBe('prop') + expect(e1.shadowRoot!.innerHTML).toBe( + `
prop
`, + ) + }) + + test('set DOM property before resolve', async () => { + const E = defineVaporCustomElement( + defineVaporAsyncComponent(() => { + return Promise.resolve({ + props: ['msg'], + setup(props: any) { + expect(typeof props.msg).toBe('string') + const n0 = template('
', 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(`
hello
`) + expect(e2.shadowRoot!.innerHTML).toBe(`
world
`) + + e1.msg = 'world' + expect(e1.shadowRoot!.innerHTML).toBe(`
world
`) + + e2.msg = 'hello' + expect(e2.shadowRoot!.innerHTML).toBe(`
hello
`) + }) + + 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('
', 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 = `` + + await new Promise(r => setTimeout(r)) + + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe(`
20,number
`) + }) + + test('with slots', async () => { + const E = defineVaporCustomElement( + defineVaporAsyncComponent(() => { + return Promise.resolve({ + setup() { + const t0 = template('
fallback
') + const t1 = template('
') + 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 = `hi` + + await new Promise(r => setTimeout(r)) + + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe( + `
` + + `
fallback
` + + `
` + + `` + + `
`, + ) + }) + }) + + describe('shadowRoot: false', () => { + const E = defineVaporCustomElement({ + shadowRoot: false, + props: { + msg: { + type: String, + default: 'hello', + }, + }, + setup(props: any) { + const n0 = template('
')() 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 = `` + const e = container.childNodes[0] as VaporElement + await raf() + expect(e).toBeInstanceOf(E) + expect(e._instance).toBeTruthy() + expect(e.innerHTML).toBe(`
hello
`) + 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('
fallback
')(), + ) + return [n0, n1, n2] + }, + }, + { shadowRoot: false } as any, + ) + customElements.define('my-el-shadowroot-false-slots', ES) + + test('should render slots', async () => { + container.innerHTML = + `` + + `defaulttext` + + `
named
` + + `
` + 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( + `defaulttext` + + `
named
` + + `
fallback
`, + ) + + toggle.value = false + await nextTick() + expect(e.innerHTML).toBe( + `defaulttext` + + `` + + `
fallback
`, + ) + }) + + 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('default')(), + }), + }) + }, + } + const app = createVaporApp(App) + app.mount(container) + await nextTick() + const e = container.childNodes[0] as VaporElement + expect(e.innerHTML).toBe( + `default`, + ) + 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('default')(), + }), + }) + }, + } + const app = createVaporApp(App) + app.mount(container) + await nextTick() + expect(target.innerHTML).toBe(`default`) + 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('
header
')(), + template('body')(), + ], + }) + }, + } + const app = createVaporApp(App) + app.mount(container) + await nextTick() + expect(target1.outerHTML).toBe( + `
header
`, + ) + expect(target2.outerHTML).toBe( + `body`, + ) + 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('
header
')(), + template('body')(), + ], + }) + }, + } + const app = createVaporApp(App) + app.mount(container) + await nextTick() + expect(target1.outerHTML).toBe(`
`) + expect(target2.outerHTML).toBe( + `body`, + ) + app.unmount() + }) + + test('toggle nested custom element with shadowRoot: false', async () => { + customElements.define( + 'my-el-child-shadow-false', + defineVaporCustomElement( + { + setup() { + const n0 = template('
')() 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('
')() 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( + `` + + `
` + + `` + + `
child
` + + `
` + + `
` + + `
`, + ) + + isShown.value = false + await nextTick() + expect(container.innerHTML).toBe( + ``, + ) + + isShown.value = true + await nextTick() + expect(container.innerHTML).toBe( + `` + + `
` + + `` + + `
child
` + + `
` + + `
` + + `
`, + ) + }) + }) + + describe('helpers', () => { + test('useHost', () => { + const Foo = defineVaporCustomElement({ + setup() { + const host = useHost()! + host.setAttribute('id', 'host') + return template('
hello
')() + }, + }) + customElements.define('my-el-use-host', Foo) + container.innerHTML = `` + 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('
hello
')() + }, + }) + customElements.define('my-el-use-shadow-root', Foo) + container.innerHTML = `` + 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('
', 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 = `` + const e = container.childNodes[0] as VaporElement & { + foo: () => void + } + expect(e.shadowRoot!.innerHTML).toBe(`
0
`) + e.foo() + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe(`
1
`) + }) + + test('expose attributes and callback', async () => { + type SetValue = (value: string) => void + let fn: MockedFunction + + 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('
', true)() as any + const x0 = txt(n0) as any + renderEffect(() => setText(x0, value.value)) + return n0 + }, + }) + customElements.define('my-el-expose', E) + + container.innerHTML = `` + const e = container.childNodes[0] as VaporElement & { + value: string + setValue: MockedFunction + } + expect(e.shadowRoot!.innerHTML).toBe(`
hello
`) + 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(`
world
`) + }) + + test('warning when exposing an existing property', () => { + const E = defineVaporCustomElement({ + props: { + value: String, + }, + setup(props: any, { expose }: any) { + expose({ + value: 'hello', + }) + + const n0 = template('
', 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 = `` + + 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('
')() as any + setInsertionState(n0, null) + createSlot('default', null) + return n0 + }, + }) + }), + ) + + const EChild = defineVaporCustomElement({ + setup() { + fooVal = inject('foo') + const n0 = template('
child
')() + return n0 + }, + }) + customElements.define('my-el-async-nested-ce', E) + customElements.define('slotted-child', EChild) + container.innerHTML = `
` + + await new Promise(r => setTimeout(r)) + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe(`
`) + 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('
')() as any + setInsertionState(n0, null) + createSlot('default', null) + return n0 + }, + }) + }), + ) + + const EChild = defineVaporCustomElement({ + setup() { + provide('bar', 'bar') + const n0 = template('
')() as any + setInsertionState(n0, null) + createSlot('default', null) + return n0 + }, + }) + + const EChild2 = defineVaporCustomElement({ + setup() { + fooVal = inject('foo') + barVal = inject('bar') + const n0 = template('
child
')() + 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 = + `` + + `
` + + `` + + `
` + + `
` + + await new Promise(r => setTimeout(r)) + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe(`
`) + expect(fooVal).toBe('foo') + expect(barVal).toBe('bar') + }) + + describe('configureApp', () => { + test('should work', () => { + const E = defineVaporCustomElement( + () => { + const msg = inject('msg') + const n0 = template('
', 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 = `` + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot?.innerHTML).toBe('
app-injected
') + }) + + test('work with async component', async () => { + const AsyncComp = defineVaporAsyncComponent(() => { + return Promise.resolve({ + setup() { + const msg = inject('msg') + const n0 = template('
', 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 = `` + const e = container.childNodes[0] as VaporElement + await new Promise(r => setTimeout(r)) + expect(e.shadowRoot?.innerHTML).toBe('
app-injected
') + }) + + test('with hmr reload', async () => { + const __hmrId = '__hmrWithApp' + const def = defineVaporComponent({ + __hmrId, + setup() { + const msg = inject('msg') + const n0 = template('
')() 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 = `` + const el = container.childNodes[0] as VaporElement + expect(el.shadowRoot?.innerHTML).toBe( + `
app-injected
`, + ) + + // hmr + __VUE_HMR_RUNTIME__.reload(__hmrId, def as any) + + await nextTick() + expect(el.shadowRoot?.innerHTML).toBe( + `
app-injected
`, + ) + }) + }) + + // #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('
')() 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('
', 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('
')() 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(`
fooValue
`) + 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 = '' + 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( + ``, + ) + + toggle.value = false + await nextTick() + expect(el.hasAttribute('foo-bar')).toBe(false) + expect((el as any).outerHTML).toBe( + ``, + ) + }) + + 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', + }) + }) +}) diff --git a/packages/runtime-vapor/src/apiCreateApp.ts b/packages/runtime-vapor/src/apiCreateApp.ts index 37e77e8831e..234c79fac8c 100644 --- a/packages/runtime-vapor/src/apiCreateApp.ts +++ b/packages/runtime-vapor/src/apiCreateApp.ts @@ -36,14 +36,16 @@ const mountApp: AppMountFn = (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 = (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() }) diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts new file mode 100644 index 00000000000..f896b49eb5b --- /dev/null +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -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

= { + new (initialProps?: Record): VaporElement & P +} + +// TODO type inference + +/*@__NO_SIDE_EFFECTS__*/ +export function defineVaporCustomElement( + options: any, + extraOptions?: Omit, + /** + * @internal + */ + _createApp?: CreateAppFunction, +): VaporElementConstructor { + let Comp = defineVaporComponent(options, extraOptions) + if (isPlainObject(Comp)) Comp = extend({}, Comp, extraOptions) + class VaporCustomElement extends VaporElement { + static def = Comp + constructor(initialProps?: Record) { + super(Comp, initialProps, _createApp) + } + } + + return VaporCustomElement +} + +/*@__NO_SIDE_EFFECTS__*/ +export const defineVaporSSRCustomElement = (( + options: any, + extraOptions?: Omit, +) => { + 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 | undefined = {}, + createAppFn: CreateAppFunction = 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): void { + this._updateFragmentNodes( + (this._instance! as VaporComponentInstance).block, + replacements, + ) + } + + /** + * Replace slot nodes with their replace content + * @internal + */ + private _updateFragmentNodes( + block: Block, + replacements: Map, + ): 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, + ) + } +} diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index e850f08932d..3a4ad1d7c44 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -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 @@ -695,6 +720,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`) } diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 6832bd9103c..c10008b3af2 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -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 { const mergedRawProps: Record = {} for (const key in props) { if (key !== '$') { - mergedRawProps[key] = props[key]() + mergedRawProps[key] = resolveSource(props[key]) } } if (props.$) { diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index b2a3ff5fb97..01b0be5d4dd 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -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 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 diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 746798f71bf..3c061bc0b14 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -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( diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts index b104b20900d..5aae0560aec 100644 --- a/packages/runtime-vapor/src/dom/prop.ts +++ b/packages/runtime-vapor/src/dom/prop.ts @@ -1,5 +1,6 @@ import { type NormalizedStyle, + camelize, canSetValueDirectly, includeBooleanAttr, isArray, @@ -37,6 +38,7 @@ import { } from '../component' import { isHydrating, logMismatchError } from './hydration' import type { Block } from '../block' +import type { VaporElement } from '../apiDefineVaporCustomElement' type TargetElement = Element & { $root?: true @@ -98,6 +100,7 @@ export function setDOMProp( key: string, value: any, forceHydrate: boolean = false, + attrName?: string, ): void { if (!isApplyingFallthroughProps && el.$root && hasFallthroughKey(key)) { return @@ -149,7 +152,7 @@ export function setDOMProp( ) } } - needRemove && el.removeAttribute(key) + needRemove && el.removeAttribute(attrName || key) } export function setClass(el: TargetElement, value: any): void { @@ -457,6 +460,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) } @@ -476,12 +485,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( diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 0d718747d24..98bfda025d0 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -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' diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index 3c937c0ed58..e36ac4ba458 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -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 index 00000000000..c50bff6709c --- /dev/null +++ b/packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts @@ -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(` + + + +

${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( + ``, + ) + + 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('')() + 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') +}) diff --git a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts index c875f1bee69..c39286d3d12 100644 --- a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts +++ b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts @@ -78,49 +78,6 @@ test('ssr custom element hydration', async () => { await assertInteraction('my-element-async') }) -test('work with Teleport (shadowRoot: false)', async () => { - await setContent( - `
default`, - ) - - 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('default') -}) - // #11641 test('pass key to custom element', async () => { const messages: string[] = []