From e808152f14cfec42987a89a43e0cadf230039977 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 24 Oct 2025 11:53:18 +0800 Subject: [PATCH 01/24] feat: implement defineVaporCustomElement --- packages/runtime-dom/src/apiCustomElement.ts | 291 ++++++++++-------- packages/runtime-dom/src/index.ts | 1 + .../src/apiDefineVaporCustomElement.ts | 49 +++ 3 files changed, 206 insertions(+), 135 deletions(-) create mode 100644 packages/runtime-vapor/src/apiDefineVaporCustomElement.ts diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 6b1c8f0cae8..b8e5811700d 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, @@ -200,7 +200,9 @@ const BaseClass = ( type InnerComponentDef = ConcreteComponent & CustomElementOptions -export class VueElement +export abstract class VueElementBase< + Def extends CustomElementOptions & { props?: any } = InnerComponentDef, + > extends BaseClass implements ComponentCustomElementInterface { @@ -208,7 +210,7 @@ export class VueElement /** * @internal */ - _instance: ComponentInternalInstance | null = null + _instance: GenericComponentInstance | null = null /** * @internal */ @@ -220,41 +222,48 @@ export class VueElement /** * @internal */ - _nonce: string | undefined = this._def.nonce - + _nonce: string | undefined /** * @internal */ _teleportTargets?: Set - private _connected = false - private _resolved = false - private _numberProps: Record | null = null - private _styleChildren = new WeakSet() - private _pendingResolve: Promise | undefined - private _parent: VueElement | undefined + protected _def: Def + protected _props: Record + protected _createApp: CreateAppFunction + protected _connected = false + protected _resolved = false + protected _numberProps: Record | null = null + protected _styleChildren: WeakSet = new WeakSet() + protected _pendingResolve: Promise | undefined + protected _parent: VueElementBase | undefined + /** * 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 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 = {}, + createAppFn: CreateAppFunction = createApp, ) { super() - if (this.shadowRoot && _createApp !== createApp) { + this._def = def + this._props = props + this._createApp = createAppFn + this._nonce = def.nonce + if (this.shadowRoot && createAppFn !== createApp) { this._root = this.shadowRoot } else { if (__DEV__ && this.shadowRoot) { @@ -263,9 +272,9 @@ export class VueElement `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, ) @@ -291,7 +300,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 } @@ -299,7 +308,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(() => { @@ -313,14 +322,22 @@ export class VueElement } } - private _setParent(parent = this._parent) { - if (parent) { - this._instance!.parent = parent._instance + protected abstract _mountComponent(def: Def): void + protected abstract _updateComponent(): void + protected abstract _unmountComponent(): void + + protected _setParent( + parent: VueElementBase | undefined = this._parent, + ): void { + if (parent && this._instance) { + this._instance.parent = parent._instance this._inheritParentContext(parent) } } - private _inheritParentContext(parent = this._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) { @@ -339,10 +356,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._unmountComponent() if (this._teleportTargets) { this._teleportTargets.clear() this._teleportTargets = undefined @@ -375,7 +389,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 @@ -410,51 +424,23 @@ 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) - - // apply expose after mount - const exposed = this._instance && this._instance.exposed - if (!exposed) return - for (const key in exposed) { - if (!hasOwn(this, key)) { - // exposed properties are readonly - Object.defineProperty(this, key, { - // unwrap ref to be consistent with public instance behavior - get: () => unref(exposed[key]), - }) - } else if (__DEV__) { - warn(`Exposed property "${key}" already exists on custom element.`) - } - } - } - - private _resolveProps(def: InnerComponentDef) { + private _resolveProps(def: Def): void { const { props } = def const declaredPropKeys = isArray(props) ? props : Object.keys(props || {}) @@ -478,7 +464,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 @@ -511,12 +497,12 @@ 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 } } if (shouldUpdate && this._instance) { - this._update() + this._updateComponent() } // reflect if (shouldReflect) { @@ -537,69 +523,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)) { @@ -635,7 +562,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 = @@ -648,7 +575,7 @@ export class VueElement /** * Only called when shadowRoot is false */ - private _renderSlots() { + protected _renderSlots(): void { const outlets = this._getSlots() const scopeId = this._instance!.type.__scopeId for (let i = 0; i < outlets.length; i++) { @@ -690,6 +617,7 @@ export class VueElement return res }, []) } + /** * @internal */ @@ -715,9 +643,102 @@ export class VueElement } } -export function useHost(caller?: string): VueElement | null { +export class VueElement extends VueElementBase { + protected _mountComponent(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) + + const exposed = this._instance && this._instance.exposed + if (!exposed) return + for (const key in exposed) { + if (!hasOwn(this, key)) { + Object.defineProperty(this, key, { + get: () => unref(exposed[key]), + }) + } else if (__DEV__) { + warn(`Exposed property "${key}" already exists on custom element.`) + } + } + } + + protected _updateComponent(): void { + if (!this._app) return + const vnode = this._createVNode() + vnode.appContext = this._app._context + render(vnode, this._root) + } + + protected _unmountComponent(): void { + if (this._app) { + this._app.unmount() + } + if (this._instance && this._instance.ce) { + this._instance.ce = undefined + } + this._app = this._instance = null + } + + 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 + if (__DEV__) { + instance.ceReload = newStyles => { + if (this._styles) { + this._styles.forEach(s => this._root.removeChild(s)) + this._styles.length = 0 + } + this._applyStyles(newStyles) + this._instance = null + this._updateComponent() + } + } + + const dispatch = (event: string, args: any[]) => { + this.dispatchEvent( + new CustomEvent( + event, + isPlainObject(args[0]) + ? extend({ detail: args }, args[0]) + : { detail: args }, + ), + ) + } + + instance.emit = (event: string, ...args: any[]) => { + dispatch(event, args) + if (hyphenate(event) !== event) { + dispatch(hyphenate(event), args) + } + } + + this._setParent() + } + } + return vnode + } +} + +export function useHost(caller?: string): VueElementBase | null { const instance = getCurrentInstance() - const el = instance && (instance.ce as VueElement) + const el = instance && (instance.ce as VueElementBase) if (el) { return el } else if (__DEV__) { 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/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts new file mode 100644 index 00000000000..bda741a0b7f --- /dev/null +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -0,0 +1,49 @@ +import { extend, isPlainObject } from '@vue/shared' +import { defineVaporComponent } from '.' +import { + type CreateAppFunction, + type CustomElementOptions, + VueElementBase, +} from '@vue/runtime-dom' +import type { ObjectVaporComponent, VaporComponent } from './component' + +export type VaporElementConstructor

= { + new (initialProps?: Record): VaporElement & P +} + +// TODO type inference + +/*@__NO_SIDE_EFFECTS__*/ +export function defineCustomElement( + 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 +} + +type VaporInnerComponentDef = VaporComponent & CustomElementOptions + +export class VaporElement extends VueElementBase { + protected _mountComponent(def: VaporInnerComponentDef): void { + throw new Error('Method not implemented.') + } + protected _updateComponent(): void { + throw new Error('Method not implemented.') + } + protected _unmountComponent(): void { + throw new Error('Method not implemented.') + } +} From da010a70358e2f524c22374a93f5f79100274805 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 24 Oct 2025 15:10:10 +0800 Subject: [PATCH 02/24] wip: enhance VaporElement with type parameters and pre-rendering support --- packages/runtime-dom/src/apiCustomElement.ts | 58 +++++++++++++------ .../src/apiDefineVaporCustomElement.ts | 43 ++++++++++++-- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index b8e5811700d..91ba8e60602 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -201,6 +201,8 @@ const BaseClass = ( type InnerComponentDef = ConcreteComponent & CustomElementOptions export abstract class VueElementBase< + E = Element, + C = Component, Def extends CustomElementOptions & { props?: any } = InnerComponentDef, > extends BaseClass @@ -218,7 +220,7 @@ export abstract class VueElementBase< /** * @internal */ - _root: Element | ShadowRoot + _root!: Element | ShadowRoot /** * @internal */ @@ -230,7 +232,7 @@ export abstract class VueElementBase< protected _def: Def protected _props: Record - protected _createApp: CreateAppFunction + protected _createApp: CreateAppFunction protected _connected = false protected _resolved = false protected _numberProps: Record | null = null @@ -249,29 +251,27 @@ export abstract class VueElementBase< protected _ob?: MutationObserver | null = null protected _slots?: Record + protected abstract _hasPreRendered(): boolean | undefined + protected abstract _mountComponent(def: Def): void + protected abstract _updateComponent(): void + protected abstract _unmountComponent(): void + constructor( /** * Component def - note this may be an AsyncWrapper, and this._def will * be overwritten by the inner component when resolved. */ def: Def, - props: Record = {}, - createAppFn: CreateAppFunction = createApp, + props: Record | undefined = {}, + createAppFn: CreateAppFunction, ) { super() this._def = def this._props = props this._createApp = createAppFn this._nonce = def.nonce - if (this.shadowRoot && createAppFn !== createApp) { - 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 (this._hasPreRendered()) { if (def.shadowRoot !== false) { this.attachShadow( extend({}, def.shadowRootOptions, { @@ -322,10 +322,6 @@ export abstract class VueElementBase< } } - protected abstract _mountComponent(def: Def): void - protected abstract _updateComponent(): void - protected abstract _unmountComponent(): void - protected _setParent( parent: VueElementBase | undefined = this._parent, ): void { @@ -643,7 +639,33 @@ export abstract class VueElementBase< } } -export class VueElement extends VueElementBase { +export class VueElement extends VueElementBase< + Element, + Component, + InnerComponentDef +> { + constructor( + def: InnerComponentDef, + props: Record | undefined = {}, + createAppFn: CreateAppFunction = createApp, + ) { + super(def, props, createAppFn) + } + + protected _hasPreRendered(): boolean | undefined { + if (this.shadowRoot && this._createApp !== createApp) { + 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\`.`, + ) + } + return true + } + } + protected _mountComponent(def: InnerComponentDef): void { if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) { // @ts-expect-error diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index bda741a0b7f..fa443268ab0 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -1,9 +1,10 @@ import { extend, isPlainObject } from '@vue/shared' -import { defineVaporComponent } from '.' +import { createVaporApp, defineVaporComponent } from '.' import { type CreateAppFunction, type CustomElementOptions, VueElementBase, + warn, } from '@vue/runtime-dom' import type { ObjectVaporComponent, VaporComponent } from './component' @@ -14,13 +15,13 @@ export type VaporElementConstructor

= { // TODO type inference /*@__NO_SIDE_EFFECTS__*/ -export function defineCustomElement( +export function defineVaporCustomElement( options: any, extraOptions?: Omit, /** * @internal */ - _createApp?: CreateAppFunction, + _createApp?: CreateAppFunction, ): VaporElementConstructor { let Comp = defineVaporComponent(options, extraOptions) if (isPlainObject(Comp)) Comp = extend({}, Comp, extraOptions) @@ -34,9 +35,43 @@ export function defineCustomElement( return VaporCustomElement } +/*@__NO_SIDE_EFFECTS__*/ +export const defineVaporSSRCustomElement = (( + options: any, + extraOptions?: Omit, +) => { + // @ts-expect-error + return defineVaporCustomElement(options, extraOptions, createVaporSSRApp) +}) as typeof defineVaporCustomElement + type VaporInnerComponentDef = VaporComponent & CustomElementOptions -export class VaporElement extends VueElementBase { +export class VaporElement extends VueElementBase< + ParentNode, + VaporComponent, + VaporInnerComponentDef +> { + constructor( + def: VaporInnerComponentDef, + props: Record | undefined = {}, + createAppFn: CreateAppFunction = createVaporApp, + ) { + super(def, props, createAppFn) + } + + protected _hasPreRendered(): boolean | undefined { + if (this.shadowRoot && this._createApp !== createVaporApp) { + 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 \`defineVaporSSRCustomElement\`.`, + ) + } + return true + } + } protected _mountComponent(def: VaporInnerComponentDef): void { throw new Error('Method not implemented.') } From 4b8a7fb729fddad6b459e0385defb60ee341f472 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 24 Oct 2025 17:13:11 +0800 Subject: [PATCH 03/24] wip: save --- packages/runtime-core/src/apiCreateApp.ts | 5 ++ packages/runtime-dom/src/apiCustomElement.ts | 88 +++++++++++-------- packages/runtime-vapor/src/apiCreateApp.ts | 36 ++++---- .../src/apiDefineVaporCustomElement.ts | 67 ++++++++++++-- packages/runtime-vapor/src/index.ts | 4 + 5 files changed, 137 insertions(+), 63 deletions(-) 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-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 91ba8e60602..61c3d7c4413 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -252,9 +252,9 @@ export abstract class VueElementBase< protected _slots?: Record protected abstract _hasPreRendered(): boolean | undefined - protected abstract _mountComponent(def: Def): void - protected abstract _updateComponent(): void - protected abstract _unmountComponent(): void + protected abstract _mount(def: Def): void + protected abstract _update(): void + protected abstract _unmount(): void constructor( /** @@ -352,7 +352,7 @@ export abstract class VueElementBase< this._ob.disconnect() this._ob = null } - this._unmountComponent() + this._unmount() if (this._teleportTargets) { this._teleportTargets.clear() this._teleportTargets = undefined @@ -436,6 +436,45 @@ export abstract class VueElementBase< } } + private _mountComponent(def: Def): void { + this._mount(def) + this._processExposed() + } + + protected _processExposed(): void { + const exposed = this._instance && this._instance.exposed + if (!exposed) return + for (const key in exposed) { + if (!hasOwn(this, key)) { + Object.defineProperty(this, key, { + get: () => unref(exposed[key]), + }) + } else if (__DEV__) { + warn(`Exposed property "${key}" already exists on custom element.`) + } + } + } + + protected _processEmit(): void { + 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) + } + } + } + private _resolveProps(def: Def): void { const { props } = def const declaredPropKeys = isArray(props) ? props : Object.keys(props || {}) @@ -498,7 +537,7 @@ export abstract class VueElementBase< } } if (shouldUpdate && this._instance) { - this._updateComponent() + this._update() } // reflect if (shouldReflect) { @@ -666,7 +705,7 @@ export class VueElement extends VueElementBase< } } - protected _mountComponent(def: InnerComponentDef): void { + protected _mount(def: InnerComponentDef): void { if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) { // @ts-expect-error def.name = 'VueElement' @@ -678,28 +717,16 @@ export class VueElement extends VueElementBase< } this._app._ceVNode = this._createVNode() this._app.mount(this._root) - - const exposed = this._instance && this._instance.exposed - if (!exposed) return - for (const key in exposed) { - if (!hasOwn(this, key)) { - Object.defineProperty(this, key, { - get: () => unref(exposed[key]), - }) - } else if (__DEV__) { - warn(`Exposed property "${key}" already exists on custom element.`) - } - } } - protected _updateComponent(): void { + protected _update(): void { if (!this._app) return const vnode = this._createVNode() vnode.appContext = this._app._context render(vnode, this._root) } - protected _unmountComponent(): void { + protected _unmount(): void { if (this._app) { this._app.unmount() } @@ -729,28 +756,11 @@ export class VueElement extends VueElementBase< } this._applyStyles(newStyles) this._instance = null - this._updateComponent() - } - } - - const dispatch = (event: string, args: any[]) => { - this.dispatchEvent( - new CustomEvent( - event, - isPlainObject(args[0]) - ? extend({ detail: args }, args[0]) - : { detail: args }, - ), - ) - } - - instance.emit = (event: string, ...args: any[]) => { - dispatch(event, args) - if (hyphenate(event) !== event) { - dispatch(hyphenate(event), args) + this._update() } } + this._processEmit() this._setParent() } } diff --git a/packages/runtime-vapor/src/apiCreateApp.ts b/packages/runtime-vapor/src/apiCreateApp.ts index 89fc6179ee0..a50c12e60e7 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 index fa443268ab0..8cc1782c444 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -1,12 +1,18 @@ import { extend, isPlainObject } from '@vue/shared' -import { createVaporApp, defineVaporComponent } from '.' +import { createComponent, createVaporApp, defineVaporComponent } from '.' import { type CreateAppFunction, type CustomElementOptions, VueElementBase, warn, } from '@vue/runtime-dom' -import type { ObjectVaporComponent, VaporComponent } from './component' +import { + type ObjectVaporComponent, + type VaporComponent, + type VaporComponentInstance, + mountComponent, + unmountComponent, +} from './component' export type VaporElementConstructor

= { new (initialProps?: Record): VaporElement & P @@ -72,13 +78,58 @@ export class VaporElement extends VueElementBase< return true } } - protected _mountComponent(def: VaporInnerComponentDef): void { - throw new Error('Method not implemented.') + 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) + } + + this._app._ceComponent = this._createComponent() + this._app!.mount(this._root) + } + + protected _update(): void { + if (!this._app) return + unmountComponent(this._instance! as VaporComponentInstance, this._root) + const instance = this._createComponent() + instance.appContext = this._app!._context + mountComponent(this._instance! as VaporComponentInstance, this._root) } - protected _updateComponent(): void { - throw new Error('Method not implemented.') + + protected _unmount(): void { + this._app!.unmount() + this._app = this._instance = null } - protected _unmountComponent(): void { - throw new Error('Method not implemented.') + + private _createComponent() { + this._instance = createComponent(this._def, this._props) + if (!this.shadowRoot) { + this._instance!.m = this._instance!.u = [this._renderSlots.bind(this)] + } + + 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) + this._instance = null + this._update() + } + } + + this._processEmit() + this._setParent() + + return this._instance } } diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 0d718747d24..d8fbbf0e97c 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' From 1a8027a517893e7abf0c753b7e8dfe4d67e43f16 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 27 Oct 2025 11:31:24 +0800 Subject: [PATCH 04/24] wip: save --- packages/runtime-dom/src/apiCustomElement.ts | 39 +- .../__tests__/customElement.spec.ts | 1960 +++++++++++++++++ .../src/apiDefineVaporCustomElement.ts | 36 +- packages/runtime-vapor/src/component.ts | 10 +- packages/runtime-vapor/src/renderEffect.ts | 2 + 5 files changed, 2002 insertions(+), 45 deletions(-) create mode 100644 packages/runtime-vapor/__tests__/customElement.spec.ts diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 61c3d7c4413..892ba8d9e0e 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -322,6 +322,10 @@ export abstract class VueElementBase< } } + get _isVapor(): boolean { + return `__vapor` in this._def + } + protected _setParent( parent: VueElementBase | undefined = this._parent, ): void { @@ -455,7 +459,22 @@ export abstract class VueElementBase< } } - protected _processEmit(): void { + 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) + this._instance = null + this._update() + } + } + const dispatch = (event: string, args: any[]) => { this.dispatchEvent( new CustomEvent( @@ -530,7 +549,7 @@ export abstract class VueElementBase< if (val === REMOVAL) { delete this._props[key] } else { - this._props[key] = val + this._props[key] = this._isVapor ? () => val : val // support set key on ceVNode if (key === 'key' && this._app && this._app._ceVNode) { this._app._ceVNode!.key = val @@ -746,21 +765,7 @@ export class VueElement extends VueElementBase< if (!this._instance) { vnode.ce = instance => { this._instance = instance - instance.ce = this - instance.isCE = true - if (__DEV__) { - instance.ceReload = newStyles => { - if (this._styles) { - this._styles.forEach(s => this._root.removeChild(s)) - this._styles.length = 0 - } - this._applyStyles(newStyles) - this._instance = null - this._update() - } - } - - this._processEmit() + this._processInstance() this._setParent() } } diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts new file mode 100644 index 00000000000..dbe79a53d66 --- /dev/null +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -0,0 +1,1960 @@ +// import type { MockedFunction } from 'vitest' +import type { VaporElement } from '../src/apiDefineVaporCustomElement' +import { + type HMRRuntime, + nextTick, + ref, + toDisplayString, +} from '@vue/runtime-dom' +import { + createComponentWithFallback, + createVaporApp, + defineVaporComponent, + defineVaporCustomElement, + delegateEvents, + 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) + + delegateEvents('input') + + beforeEach(() => { + container.innerHTML = '' + }) + + 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 + // return () => + // h('input', { + // type: 'number', + // value: props.value, + // onInput: (e: InputEvent) => { + // const num = (e.target! as HTMLInputElement).valueAsNumber + // emit('update', Number.isNaN(num) ? null : num) + // }, + // }) + }, + }) + customElements.define('my-el-input', CustomInput) + const num = ref('12') + const containerComp = defineVaporComponent({ + setup() { + // return () => { + // return h('div', [ + // h('my-el-input', { + // value: num.value, + // onUpdate: ($event: CustomEvent) => { + // num.value = $event.detail[0] + // }, + // }), + // h('div', { id: 'move' }), + // ]) + // } + const n1 = template('
', true)() as any + setInsertionState(n1, 0, true) + createComponentWithFallback('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, + // }, + // render() { + // return [ + // h('div', null, this.foo || ''), + // h('div', null, this.bazQux || (this.bar && this.bar.x)), + // ] + // }, + // }) + // customElements.define('my-el-props', E) + + // test('renders custom element w/ correct object prop value', () => { + // render(h('my-el-props', { value: { x: 1 } }), container) + // 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 () => { + // const e = new E() + // 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 () => { + // const e = new E() + // 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, + // }, + // render() { + // return 'Comp' + // }, + // }) + // customElements.define('my-el-comp', Comp) + // render(h('my-el-comp', { 'foo-bar': true }), container) + // 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, + // }, + // render() { + // return [ + // this.fooBar, + // typeof this.fooBar, + // this.bar, + // typeof this.bar, + // this.baz, + // typeof this.baz, + // ].join(' ') + // }, + // }) + // 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`) + // }) + + // // #4772 + // test('attr casting w/ programmatic creation', () => { + // const E = defineVaporCustomElement({ + // props: { + // foo: Number, + // }, + // render() { + // return `foo type: ${typeof this.foo}` + // }, + // }) + // 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) { + // expect(props.foo).toBe('hello') + // expect(props.dataAge).toBe(5) + // }, + // render() { + // return h('div', `foo: ${this.foo}`) + // }, + // }) + // 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) { + // expect(props.foo).toBe('hello') + // expect(props.post).toBe(obj) + // }, + // render() { + // return JSON.stringify(this.post) + // }, + // }) + // 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)) + // }) + + // // https://github.com/vuejs/core/issues/6163 + // test('handle components with no props', async () => { + // const E = defineVaporCustomElement({ + // render() { + // return h('div', 'foo') + // }, + // }) + // customElements.define('my-element-noprops', E) + // const el = document.createElement('my-element-noprops') + // container.appendChild(el) + // await nextTick() + // expect(el.shadowRoot!.innerHTML).toMatchInlineSnapshot('"
foo
"') + // }) + + // // #5793 + // test('set number value in dom property', () => { + // const E = defineVaporCustomElement({ + // props: { + // 'max-age': Number, + // }, + // render() { + // // @ts-expect-error + // return `max age: ${this.maxAge}/type: ${typeof this.maxAge}` + // }, + // }) + // 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') + // }) + + // // #9006 + // test('should reflect default value', () => { + // const E = defineVaporCustomElement({ + // props: { + // value: { + // type: String, + // default: 'hi', + // }, + // }, + // render() { + // return this.value + // }, + // }) + // customElements.define('my-el-default-val', E) + // container.innerHTML = `` + // const e = container.childNodes[0] as any + // expect(e.value).toBe('hi') + // }) + + // // #12214 + // test('Boolean prop with default true', async () => { + // const E = defineVaporCustomElement({ + // props: { + // foo: { + // type: Boolean, + // default: true, + // }, + // }, + // render() { + // return String(this.foo) + // }, + // }) + // 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 => { + // return () => props.text + // }, + // { + // 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, + // }, + // }, + // render() { + // return h('div', [ + // h('span', [`${this.num} is ${typeof this.num}`]), + // h('span', [`${this.bool} is ${typeof this.bool}`]), + // ]) + // }, + // }) + + // customElements.define('my-el-with-type-props', E) + // render(h('my-el-with-type-props', { num: 1, bool: true }), container) + // 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({ + // render() { + // return [h('div', null, this.$attrs.foo as string)] + // }, + // }) + // 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 + // // eslint-disable-next-line vitest/no-disabled-tests + // test.skip('shadowRoot should be initialized with delegatesFocus', () => { + // const E = defineVaporCustomElement( + // { + // render() { + // return [h('input', { tabindex: 1 })] + // }, + // }, + // { shadowRootOptions: { delegatesFocus: true } }, + // ) + // 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') + // return () => + // h('div', { + // onClick: () => { + // emit('my-click', 1) + // }, + // onMousedown: () => { + // emit('myEvent', 1) // validate hyphenation + // }, + // onWheel: () => { + // emit('my-wheel', { bubbles: true }, 1) + // }, + // }) + // }, + // }) + // 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) + // e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click')) + // expect(spy).toHaveBeenCalledTimes(1) + // expect(spy.mock.calls[0][0]).toMatchObject({ + // detail: [1], + // }) + // }) + + // // #5373 + // 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')) + // 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(defineAsyncComponent(() => 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')) + // expect(spy).toHaveBeenCalled() + // expect(spy.mock.calls[0][0]).toMatchObject({ + // detail: [1], + // }) + // }) + + // // #7293 + // test('emit in an async component wrapper with properties bound', async () => { + // const E = defineVaporCustomElement( + // defineAsyncComponent( + // () => 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')) + // 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')) + // expect(spy).toHaveBeenCalledTimes(1) + // expect(spy.mock.calls[0][0]).toMatchObject({ + // bubbles: true, + // detail: [{ bubbles: true }, 1], + // }) + // }) + // }) + + // describe('slots', () => { + // const E = defineVaporCustomElement({ + // render() { + // return [ + // h('div', null, [ + // renderSlot(this.$slots, 'default', undefined, () => [ + // h('div', 'fallback'), + // ]), + // ]), + // h('div', null, renderSlot(this.$slots, 'named')), + // ] + // }, + // }) + // 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({ + // render() { + // return [ + // h( + // 'div', + // null, + // renderSlot(this.$slots, 'default', { class: foo.value }), + // ), + // ] + // }, + // }) + // 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')! + // return () => h('div', foo.value) + // }, + // }) + // customElements.define('my-consumer', Consumer) + + // test('over nested usage', async () => { + // const foo = ref('injected!') + // const Provider = defineVaporCustomElement({ + // provide: { + // foo, + // }, + // render() { + // return h('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({ + // provide: { + // foo, + // }, + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }) + // 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({ + // provide: { + // fooA, + // }, + // render() { + // return h('provider-b') + // }, + // }) + // const ProviderB = defineVaporCustomElement({ + // provide: { + // fooB, + // }, + // render() { + // return h('my-multi-consumer') + // }, + // }) + + // const Consumer = defineVaporCustomElement({ + // setup() { + // const fooA = inject('fooA')! + // const fooB = inject('fooB')! + // return () => h('div', `${fooA.value} ${fooB.value}`) + // }, + // }) + + // 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!
`, + // ) + // }) + + // // #13212 + // 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'), + // ) + // }, + // render() { + // return h('div', [renderSlot(this.$slots, 'default')]) + // }, + // }, + // { + // configureApp(app) { + // app.provide('shared', 'shared') + // app.provide('outer', 'outer') + // }, + // }, + // ) + + // const Inner = defineVaporCustomElement( + // { + // setup() { + // // ensure values are not self-injected + // provide('inner', 'inner-child') + + // innerValues.push( + // inject('shared'), + // inject('outer'), + // inject('inner'), + // ) + // }, + // render() { + // return h('div', [renderSlot(this.$slots, 'default')]) + // }, + // }, + // { + // configureApp(app) { + // app.provide('outer', 'override-outer') + // app.provide('inner', 'inner') + // }, + // }, + // ) + + // const InnerChild = defineVaporCustomElement({ + // setup() { + // innerChildValues.push( + // inject('shared'), + // inject('outer'), + // inject('inner'), + // ) + // }, + // render() { + // return h('div') + // }, + // }) + + // 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; }`], + // render() { + // return h('div', 'hello') + // }, + // }) + // 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 = () => h(Bar) + // const Bar = defineVaporComponent({ + // __hmrId: 'bar', + // styles: [`div { color: green; }`, `div { color: blue; }`], + // render() { + // return 'bar' + // }, + // }) + // const Foo = defineVaporCustomElement({ + // styles: [`div { color: red; }`], + // render() { + // return [h(Baz), h(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; }`], + // render() { + // return 'bar' + // }, + // }) + // const Baz = () => h(Bar) + // const Foo = defineVaporCustomElement( + // { + // render() { + // return [h(Baz)] + // }, + // }, + // { shadowRoot: false }, + // ) + + // 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; }`], + // render() { + // return h('div', 'hello') + // }, + // }, + // { nonce: 'xxx' }, + // ) + // 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( + // defineAsyncComponent(() => { + // loaderSpy() + // return Promise.resolve({ + // props: ['msg'], + // styles: [`div { color: red }`], + // render(this: any) { + // return h('div', null, this.msg) + // }, + // }) + // }), + // ) + // customElements.define('my-el-async', E) + // container.innerHTML = + // `` + + // `` + + // await new Promise(r => setTimeout(r)) + + // // loader should be called only once + // expect(loaderSpy).toHaveBeenCalledTimes(1) + + // const e1 = container.childNodes[0] as 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( + // defineAsyncComponent(() => { + // return Promise.resolve({ + // props: ['msg'], + // setup(props) { + // expect(typeof props.msg).toBe('string') + // }, + // render(this: any) { + // return h('div', this.msg) + // }, + // }) + // }), + // ) + // customElements.define('my-el-async-2', E) + + // const e1 = new E() + + // // set property before connect + // e1.msg = 'hello' + + // const e2 = new E() + + // container.appendChild(e1) + // container.appendChild(e2) + + // // set property after connect but before resolve + // e2.msg = 'world' + + // await new Promise(r => setTimeout(r)) + + // expect(e1.shadowRoot!.innerHTML).toBe(`
hello
`) + // expect(e2.shadowRoot!.innerHTML).toBe(`
world
`) + + // e1.msg = 'world' + // expect(e1.shadowRoot!.innerHTML).toBe(`
world
`) + + // e2.msg = 'hello' + // expect(e2.shadowRoot!.innerHTML).toBe(`
hello
`) + // }) + + // test('Number prop casting before resolve', async () => { + // const E = defineVaporCustomElement( + // defineAsyncComponent(() => { + // return Promise.resolve({ + // props: { n: Number }, + // setup(props) { + // expect(props.n).toBe(20) + // }, + // render(this: any) { + // return h('div', this.n + ',' + typeof this.n) + // }, + // }) + // }), + // ) + // 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( + // defineAsyncComponent(() => { + // return Promise.resolve({ + // render(this: any) { + // return [ + // h('div', null, [ + // renderSlot(this.$slots, 'default', undefined, () => [ + // h('div', 'fallback'), + // ]), + // ]), + // h('div', null, renderSlot(this.$slots, 'named')), + // ] + // }, + // }) + // }), + // ) + // 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', + // }, + // }, + // render() { + // return h('div', this.msg) + // }, + // }) + // 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( + // { + // render() { + // return [ + // renderSlot(this.$slots, 'default'), + // toggle.value ? renderSlot(this.$slots, 'named') : null, + // renderSlot(this.$slots, 'omitted', {}, () => [ + // h('div', 'fallback'), + // ]), + // ] + // }, + // }, + // { shadowRoot: false }, + // ) + // 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') + // }) + // }, + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-child', Child) + + // const Parent = defineVaporCustomElement( + // { + // setup() { + // calls.push('parent rendering') + // onMounted(() => { + // calls.push('parent mounted') + // }) + // }, + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-parent', Parent) + + // const App = { + // render() { + // return h('my-parent', null, { + // default: () => [ + // h('my-child', null, { + // default: () => [h('span', null, '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( + // { + // render() { + // return h( + // Teleport, + // { to: target }, + // { + // default: () => [renderSlot(this.$slots, 'default')], + // }, + // ) + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-teleport-child', Child) + // const Parent = defineVaporCustomElement( + // { + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-teleport-parent', Parent) + + // const App = { + // render() { + // return h('my-el-teleport-parent', null, { + // default: () => [ + // h('my-el-teleport-child', null, { + // default: () => [h('span', null, '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( + // { + // render() { + // return [ + // h(Teleport, { to: target1 }, [renderSlot(this.$slots, 'header')]), + // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), + // ] + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-two-teleport-child', Child) + + // const App = { + // render() { + // return h('my-el-two-teleport-child', null, { + // default: () => [ + // h('div', { slot: 'header' }, 'header'), + // h('span', { slot: 'body' }, '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( + // { + // render() { + // return [ + // // with disabled: true + // h(Teleport, { to: target1, disabled: true }, [ + // renderSlot(this.$slots, 'header'), + // ]), + // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), + // ] + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-two-teleport-child-0', Child) + + // const App = { + // render() { + // return h('my-el-two-teleport-child-0', null, { + // default: () => [ + // h('div', { slot: 'header' }, 'header'), + // h('span', { slot: 'body' }, '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( + // { + // render(ctx: any) { + // return h('div', null, [renderSlot(ctx.$slots, 'default')]) + // }, + // }, + // { shadowRoot: false }, + // ), + // ) + // const ChildWrapper = { + // render() { + // return h('my-el-child-shadow-false', null, 'child') + // }, + // } + + // customElements.define( + // 'my-el-parent-shadow-false', + // defineVaporCustomElement( + // { + // props: { + // isShown: { type: Boolean, required: true }, + // }, + // render(ctx: any, _: any, $props: any) { + // return $props.isShown + // ? h('div', { key: 0 }, [renderSlot(ctx.$slots, 'default')]) + // : null + // }, + // }, + // { shadowRoot: false }, + // ), + // ) + // const ParentWrapper = { + // props: { + // isShown: { type: Boolean, required: true }, + // }, + // render(ctx: any, _: any, $props: any) { + // return h('my-el-parent-shadow-false', { isShown: $props.isShown }, [ + // renderSlot(ctx.$slots, 'default'), + // ]) + // }, + // } + + // const isShown = ref(true) + // const App = { + // render() { + // return h(ParentWrapper, { isShown: isShown.value } as any, { + // default: () => [h(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 () => h('div', '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 () => h('div', '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({ + // data() { + // return { + // value: 0, + // } + // }, + // methods: { + // foo() { + // ;(this as any).value++ + // }, + // }, + // expose: ['foo'], + // render(_ctx: any) { + // return h('div', null, _ctx.value) + // }, + // }) + // 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(_, { expose }) { + // const value = ref('hello') + + // const setValue = (fn = vi.fn((_value: string) => { + // value.value = _value + // })) + + // expose({ + // setValue, + // value, + // }) + + // return () => h('div', null, [value.value]) + // }, + // }) + // 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, { expose }) { + // expose({ + // value: 'hello', + // }) + + // return () => h('div', null, [props.value]) + // }, + // }) + // 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( + // defineAsyncComponent(() => { + // return Promise.resolve({ + // setup(props) { + // provide('foo', 'foo') + // }, + // render(this: any) { + // return h('div', null, [renderSlot(this.$slots, 'default')]) + // }, + // }) + // }), + // ) + + // const EChild = defineVaporCustomElement({ + // setup(props) { + // fooVal = inject('foo') + // }, + // render(this: any) { + // return h('div', null, 'child') + // }, + // }) + // 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( + // defineAsyncComponent(() => { + // return Promise.resolve({ + // setup(props) { + // provide('foo', 'foo') + // }, + // render(this: any) { + // return h('div', null, [renderSlot(this.$slots, 'default')]) + // }, + // }) + // }), + // ) + + // const EChild = defineVaporCustomElement({ + // setup(props) { + // provide('bar', 'bar') + // }, + // render(this: any) { + // return h('div', null, [renderSlot(this.$slots, 'default')]) + // }, + // }) + + // const EChild2 = defineVaporCustomElement({ + // setup(props) { + // fooVal = inject('foo') + // barVal = inject('bar') + // }, + // render(this: any) { + // return h('div', null, 'child') + // }, + // }) + // 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') + // return () => h('div', msg!) + // }, + // { + // configureApp(app) { + // app.provide('msg', 'app-injected') + // }, + // }, + // ) + // customElements.define('my-element-with-app', E) + + // container.innerHTML = `` + // const e = container.childNodes[0] as VaporElement + + // expect(e.shadowRoot?.innerHTML).toBe('
app-injected
') + // }) + + // // #12448 + // test('work with async component', async () => { + // const AsyncComp = defineAsyncComponent(() => { + // return Promise.resolve({ + // render() { + // const msg: string | undefined = inject('msg') + // return h('div', {}, msg) + // }, + // } as any) + // }) + // const E = defineVaporCustomElement(AsyncComp, { + // configureApp(app) { + // app.provide('msg', 'app-injected') + // }, + // }) + // 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') + // return { msg } + // }, + // render(this: any) { + // return h('div', [h('span', this.msg), h('span', this.$foo)]) + // }, + // }) + // const E = defineVaporCustomElement(def, { + // configureApp(app) { + // app.provide('msg', 'app-injected') + // app.config.globalProperties.$foo = 'foo' + // }, + // }) + // customElements.define('my-element-with-app-hmr', E) + + // container.innerHTML = `` + // const el = container.childNodes[0] as VaporElement + // expect(el.shadowRoot?.innerHTML).toBe( + // `
app-injectedfoo
`, + // ) + + // // hmr + // __VUE_HMR_RUNTIME__.reload(__hmrId, def as any) + + // await nextTick() + // expect(el.shadowRoot?.innerHTML).toBe( + // `
app-injectedfoo
`, + // ) + // }) + // }) + + // // #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' + // }, + // }), + // ]) + // }, + // }).mount(container) + // expect(container.children[0].children[0].shadowRoot?.innerHTML).toBe( + // 'hello', + // ) + // }) + + // // #11081 + // test('Props can be casted when mounting custom elements in component rendering functions', async () => { + // const E = defineVaporCustomElement( + // defineAsyncComponent(() => + // Promise.resolve({ + // props: ['fooValue'], + // setup(props) { + // expect(props.fooValue).toBe('fooValue') + // return () => h('div', props.fooValue) + // }, + // }), + // ), + // ) + // customElements.define('my-el-async-4', E) + // const R = defineVaporComponent({ + // setup() { + // const fooValue = ref('fooValue') + // return () => { + // return h('div', null, [ + // h('my-el-async-4', { + // fooValue: fooValue.value, + // }), + // ]) + // } + // }, + // }) + + // 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() + // }) + + // // #11276 + // test('delete prop on attr removal', async () => { + // const E = defineVaporCustomElement({ + // props: { + // boo: { + // type: Boolean, + // }, + // }, + // render() { + // return this.boo + ',' + typeof this.boo + // }, + // }) + // 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, + // }, + // }, + // render() { + // return this.fooBar + // }, + // }) + // customElements.define('el-hyphenated-attr-removal', E) + // const toggle = ref(true) + // const Comp = { + // render() { + // return h('el-hyphenated-attr-removal', { + // 'foo-bar': toggle.value ? '' : null, + // }) + // }, + // } + // render(h(Comp), container) + // 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 = { + // name: 'Foo', + // } + + // defineVaporCustomElement(Foo, { shadowRoot: false }) + + // expect(Foo).toEqual({ + // name: 'Foo', + // }) + // }) +}) diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index 8cc1782c444..da4a934b064 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -6,12 +6,10 @@ import { VueElementBase, warn, } from '@vue/runtime-dom' -import { - type ObjectVaporComponent, - type VaporComponent, - type VaporComponentInstance, - mountComponent, - unmountComponent, +import type { + ObjectVaporComponent, + VaporComponent, + VaporComponentInstance, } from './component' export type VaporElementConstructor

= { @@ -83,6 +81,7 @@ export class VaporElement extends VueElementBase< def.name = 'VaporElement' } + def.isCE = true this._app = this._createApp(this._def) this._inheritParentContext() if (this._def.configureApp) { @@ -95,10 +94,10 @@ export class VaporElement extends VueElementBase< protected _update(): void { if (!this._app) return - unmountComponent(this._instance! as VaporComponentInstance, this._root) - const instance = this._createComponent() - instance.appContext = this._app!._context - mountComponent(this._instance! as VaporComponentInstance, this._root) + // 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 { @@ -112,22 +111,7 @@ export class VaporElement extends VueElementBase< this._instance!.m = this._instance!.u = [this._renderSlots.bind(this)] } - 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) - this._instance = null - this._update() - } - } - - this._processEmit() + this._processInstance() this._setParent() return this._instance diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index e9e288031f4..f335256dc44 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -127,6 +127,7 @@ export interface ObjectVaporComponent name?: string vapor?: boolean + isCE?: boolean __asyncLoader?: () => Promise __asyncResolved?: VaporComponent } @@ -489,8 +490,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 @@ -517,12 +525,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, diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index 3c937c0ed58..8aece8ee928 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.isCE) { // register effect for stopping them during HMR rerender ;(instance.renderEffects || (instance.renderEffects = [])).push(this) } From 2ca34e6dd005f1eb1a0dc108ea0dbf9a8d3a5e2f Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 27 Oct 2025 16:55:59 +0800 Subject: [PATCH 05/24] wip: process custom element as component the template helper cannot resolve them properly they require creation via createElement --- .../src/generators/component.ts | 10 +- packages/compiler-vapor/src/ir/index.ts | 1 + .../src/transforms/transformElement.ts | 12 +- packages/runtime-dom/src/apiCustomElement.ts | 2 +- .../__tests__/customElement.spec.ts | 745 +++++++++--------- packages/runtime-vapor/src/dom/prop.ts | 11 +- 6 files changed, 407 insertions(+), 374 deletions(-) diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts index 38dd701704e..fd770f16d35 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 + : operation.isCustomElement ? helper('createComponentWithFallback') - : helper('createComponent'), + : 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..136f29d8a4e 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, @@ -88,6 +93,7 @@ export const transformElement: NodeTransform = (node, context) => { singleRoot, context, isDynamicComponent, + isCustomElement, ) } else { transformNativeElement( @@ -107,6 +113,7 @@ function transformComponentElement( singleRoot: boolean, context: TransformContext, isDynamicComponent: boolean, + isCustomElement: boolean, ) { const dynamicComponent = isDynamicComponent ? resolveDynamicComponent(node) @@ -115,7 +122,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 +167,7 @@ function transformComponentElement( slots: [...context.slots], once: context.inVOnce, dynamic: dynamicComponent, + isCustomElement, } context.slots = [] } diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 892ba8d9e0e..b413bf74e9c 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -533,7 +533,7 @@ export abstract class VueElementBase< * @internal */ protected _getProp(key: string): any { - return this._props[key] + return this._isVapor ? this._props[key]() : this._props[key] } /** diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index dbe79a53d66..ff8638ac264 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -28,6 +28,20 @@ describe('defineVaporCustomElement', () => { delegateEvents('input') + function render(tag: string, props: any) { + const root = document.createElement('div') + document.body.appendChild(root) + createVaporApp({ + setup() { + return createComponentWithFallback(tag, props, null, true) + }, + }).mount(root) + + return { + container: root, + } + } + beforeEach(() => { container.innerHTML = '' }) @@ -90,32 +104,12 @@ describe('defineVaporCustomElement', () => { setValue(n0, props.value) }) return n0 - // return () => - // h('input', { - // type: 'number', - // value: props.value, - // onInput: (e: InputEvent) => { - // const num = (e.target! as HTMLInputElement).valueAsNumber - // emit('update', Number.isNaN(num) ? null : num) - // }, - // }) }, }) customElements.define('my-el-input', CustomInput) const num = ref('12') const containerComp = defineVaporComponent({ setup() { - // return () => { - // return h('div', [ - // h('my-el-input', { - // value: num.value, - // onUpdate: ($event: CustomEvent) => { - // num.value = $event.detail[0] - // }, - // }), - // h('div', { id: 'move' }), - // ]) - // } const n1 = template('

', true)() as any setInsertionState(n1, 0, true) createComponentWithFallback('my-el-input', { @@ -143,385 +137,402 @@ describe('defineVaporCustomElement', () => { 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('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
') - // }) + 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, - // }, - // render() { - // return [ - // h('div', null, this.foo || ''), - // h('div', null, this.bazQux || (this.bar && this.bar.x)), - // ] - // }, - // }) - // customElements.define('my-el-props', E) + 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', () => { - // render(h('my-el-props', { value: { x: 1 } }), container) - // const el = container.children[0] - // expect((el as any).value).toEqual({ x: 1 }) - // }) + test('renders custom element w/ correct object prop value', () => { + const { container } = render('my-el-props', { + value: () => ({ + 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
') + const el = container.children[0] + expect((el as any).value).toEqual({ x: 1 }) + }) - // // change attr - // e.setAttribute('foo', 'changed') - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe('
changed
bye
') + 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
') - // e.setAttribute('baz-qux', 'changed') - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe( - // '
changed
changed
', - // ) - // }) + // change attr + e.setAttribute('foo', 'changed') + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
changed
bye
') - // test('props via properties', async () => { - // const e = new E() - // e.foo = 'one' - // e.bar = { x: 'two' } - // container.appendChild(e) - // expect(e.shadowRoot!.innerHTML).toBe('
one
two
') + e.setAttribute('baz-qux', 'changed') + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + '
changed
changed
', + ) + }) - // // reflect - // // should reflect primitive value - // expect(e.getAttribute('foo')).toBe('one') - // // should not reflect rich data - // expect(e.hasAttribute('bar')).toBe(false) + 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
') - // e.foo = 'three' - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe('
three
two
') - // expect(e.getAttribute('foo')).toBe('three') + // reflect + // should reflect primitive value + expect(e.getAttribute('foo')).toBe('one') + // should not reflect rich data + expect(e.hasAttribute('bar')).toBe(false) - // e.foo = null - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe('
two
') - // expect(e.hasAttribute('foo')).toBe(false) + e.foo = 'three' + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
three
two
') + expect(e.getAttribute('foo')).toBe('three') - // e.foo = undefined - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe('
two
') - // expect(e.hasAttribute('foo')).toBe(false) - // expect(e.foo).toBe(undefined) + e.foo = null + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
two
') + expect(e.hasAttribute('foo')).toBe(false) - // e.bazQux = 'four' - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe('
four
') - // expect(e.getAttribute('baz-qux')).toBe('four') - // }) + e.foo = undefined + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
two
') + expect(e.hasAttribute('foo')).toBe(false) + expect(e.foo).toBe(undefined) - // test('props via attributes and properties changed together', async () => { - // const e = new E() - // e.foo = 'foo1' - // e.bar = { x: 'bar1' } - // container.appendChild(e) - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe('
foo1
bar1
') + e.bazQux = 'four' + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
four
') + expect(e.getAttribute('baz-qux')).toBe('four') + }) - // // 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) + 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 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) - // }) + // 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) - // test('props via hyphen property', async () => { - // const Comp = defineVaporCustomElement({ - // props: { - // fooBar: Boolean, - // }, - // render() { - // return 'Comp' - // }, - // }) - // customElements.define('my-el-comp', Comp) - // render(h('my-el-comp', { 'foo-bar': true }), container) - // const el = container.children[0] - // expect((el as any).outerHTML).toBe('') - // }) + // 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('attribute -> prop type casting', async () => { - // const E = defineVaporCustomElement({ - // props: { - // fooBar: Number, // test casting of camelCase prop names - // bar: Boolean, - // baz: String, - // }, - // render() { - // return [ - // this.fooBar, - // typeof this.fooBar, - // this.bar, - // typeof this.bar, - // this.baz, - // typeof this.baz, - // ].join(' ') - // }, - // }) - // 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`, - // ) + test('props via hyphen property', async () => { + const Comp = defineVaporCustomElement({ + props: { + fooBar: Boolean, + }, + setup() { + return template('Comp')() + }, + }) + customElements.define('my-el-comp', Comp) - // e.setAttribute('bar', '') - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean 12345 string`) + const { container } = render('my-el-comp', { + 'foo-bar': () => true, + }) - // e.setAttribute('foo-bar', '2e1') - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe( - // `20 number true boolean 12345 string`, - // ) + const el = container.children[0] + expect((el as any).outerHTML).toBe('') + }) - // e.setAttribute('baz', '2e1') - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean 2e1 string`) - // }) + // test('attribute -> prop type casting', async () => { + // const E = defineVaporCustomElement({ + // props: { + // fooBar: Number, // test casting of camelCase prop names + // bar: Boolean, + // baz: String, + // }, + // render() { + // return [ + // this.fooBar, + // typeof this.fooBar, + // this.bar, + // typeof this.bar, + // this.baz, + // typeof this.baz, + // ].join(' ') + // }, + // }) + // 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`, + // ) - // // #4772 - // test('attr casting w/ programmatic creation', () => { - // const E = defineVaporCustomElement({ - // props: { - // foo: Number, - // }, - // render() { - // return `foo type: ${typeof this.foo}` - // }, - // }) - // 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`) - // }) + // e.setAttribute('bar', '') + // await nextTick() + // expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean 12345 string`) - // test('handling properties set before upgrading', () => { - // const E = defineVaporCustomElement({ - // props: { - // foo: String, - // dataAge: Number, - // }, - // setup(props) { - // expect(props.foo).toBe('hello') - // expect(props.dataAge).toBe(5) - // }, - // render() { - // return h('div', `foo: ${this.foo}`) - // }, - // }) - // 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) - // }) + // e.setAttribute('foo-bar', '2e1') + // await nextTick() + // expect(e.shadowRoot!.innerHTML).toBe( + // `20 number true boolean 12345 string`, + // ) - // test('handle properties set before connecting', () => { - // const obj = { a: 1 } - // const E = defineVaporCustomElement({ - // props: { - // foo: String, - // post: Object, - // }, - // setup(props) { - // expect(props.foo).toBe('hello') - // expect(props.post).toBe(obj) - // }, - // render() { - // return JSON.stringify(this.post) - // }, - // }) - // customElements.define('my-el-preconnect', E) - // const el = document.createElement('my-el-preconnect') as any - // el.foo = 'hello' - // el.post = obj + // e.setAttribute('baz', '2e1') + // await nextTick() + // expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean 2e1 string`) + // }) - // container.appendChild(el) - // expect(el.shadowRoot.innerHTML).toBe(JSON.stringify(obj)) - // }) + // // #4772 + // test('attr casting w/ programmatic creation', () => { + // const E = defineVaporCustomElement({ + // props: { + // foo: Number, + // }, + // render() { + // return `foo type: ${typeof this.foo}` + // }, + // }) + // 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`) + // }) - // // https://github.com/vuejs/core/issues/6163 - // test('handle components with no props', async () => { - // const E = defineVaporCustomElement({ - // render() { - // return h('div', 'foo') - // }, - // }) - // 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('handling properties set before upgrading', () => { + // const E = defineVaporCustomElement({ + // props: { + // foo: String, + // dataAge: Number, + // }, + // setup(props) { + // expect(props.foo).toBe('hello') + // expect(props.dataAge).toBe(5) + // }, + // render() { + // return h('div', `foo: ${this.foo}`) + // }, + // }) + // 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) + // }) - // // #5793 - // test('set number value in dom property', () => { - // const E = defineVaporCustomElement({ - // props: { - // 'max-age': Number, - // }, - // render() { - // // @ts-expect-error - // return `max age: ${this.maxAge}/type: ${typeof this.maxAge}` - // }, - // }) - // 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('handle properties set before connecting', () => { + // const obj = { a: 1 } + // const E = defineVaporCustomElement({ + // props: { + // foo: String, + // post: Object, + // }, + // setup(props) { + // expect(props.foo).toBe('hello') + // expect(props.post).toBe(obj) + // }, + // render() { + // return JSON.stringify(this.post) + // }, + // }) + // 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)) + // }) - // // #9006 - // test('should reflect default value', () => { - // const E = defineVaporCustomElement({ - // props: { - // value: { - // type: String, - // default: 'hi', - // }, - // }, - // render() { - // return this.value - // }, - // }) - // customElements.define('my-el-default-val', E) - // container.innerHTML = `` - // const e = container.childNodes[0] as any - // expect(e.value).toBe('hi') - // }) + // // https://github.com/vuejs/core/issues/6163 + // test('handle components with no props', async () => { + // const E = defineVaporCustomElement({ + // render() { + // return h('div', 'foo') + // }, + // }) + // customElements.define('my-element-noprops', E) + // const el = document.createElement('my-element-noprops') + // container.appendChild(el) + // await nextTick() + // expect(el.shadowRoot!.innerHTML).toMatchInlineSnapshot('"
foo
"') + // }) - // // #12214 - // test('Boolean prop with default true', async () => { - // const E = defineVaporCustomElement({ - // props: { - // foo: { - // type: Boolean, - // default: true, - // }, - // }, - // render() { - // return String(this.foo) - // }, - // }) - // 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') - // }) + // // #5793 + // test('set number value in dom property', () => { + // const E = defineVaporCustomElement({ + // props: { + // 'max-age': Number, + // }, + // render() { + // // @ts-expect-error + // return `max age: ${this.maxAge}/type: ${typeof this.maxAge}` + // }, + // }) + // 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('support direct setup function syntax with extra options', () => { - // const E = defineVaporCustomElement( - // props => { - // return () => props.text - // }, - // { - // 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') - // }) + // // #9006 + // test('should reflect default value', () => { + // const E = defineVaporCustomElement({ + // props: { + // value: { + // type: String, + // default: 'hi', + // }, + // }, + // render() { + // return this.value + // }, + // }) + // customElements.define('my-el-default-val', E) + // container.innerHTML = `` + // const e = container.childNodes[0] as any + // expect(e.value).toBe('hi') + // }) - // test('prop types validation', async () => { - // const E = defineVaporCustomElement({ - // props: { - // num: { - // type: [Number, String], - // }, - // bool: { - // type: Boolean, - // }, - // }, - // render() { - // return h('div', [ - // h('span', [`${this.num} is ${typeof this.num}`]), - // h('span', [`${this.bool} is ${typeof this.bool}`]), - // ]) - // }, - // }) + // // #12214 + // test('Boolean prop with default true', async () => { + // const E = defineVaporCustomElement({ + // props: { + // foo: { + // type: Boolean, + // default: true, + // }, + // }, + // render() { + // return String(this.foo) + // }, + // }) + // 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') + // }) - // customElements.define('my-el-with-type-props', E) - // render(h('my-el-with-type-props', { num: 1, bool: true }), container) - // 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
', - // ) - // }) - // }) + // test('support direct setup function syntax with extra options', () => { + // const E = defineVaporCustomElement( + // props => { + // return () => props.text + // }, + // { + // 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, + // }, + // }, + // render() { + // return h('div', [ + // h('span', [`${this.num} is ${typeof this.num}`]), + // h('span', [`${this.bool} is ${typeof this.bool}`]), + // ]) + // }, + // }) + + // customElements.define('my-el-with-type-props', E) + // render(h('my-el-with-type-props', { num: 1, bool: true }), container) + // 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({ diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts index b104b20900d..7642dab9627 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) } From 8c1fc4d170a73626aa202b48d9f49e9ce007fbed Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 27 Oct 2025 21:03:35 +0800 Subject: [PATCH 06/24] fix: ignore errors caused by accessing Node after the test environment has been torn down --- packages/runtime-dom/src/apiCustomElement.ts | 38 +++++++++---------- .../__tests__/customElement.spec.ts | 11 +++--- .../src/apiDefineVaporCustomElement.ts | 19 +++++++++- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index b413bf74e9c..664872ad13c 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -285,6 +285,10 @@ export abstract class VueElementBase< } } + get _isVapor(): boolean { + return `__vapor` in this._def + } + connectedCallback(): void { // avoid resolving component if it's not connected if (!this.isConnected) return @@ -322,8 +326,21 @@ export abstract class VueElementBase< } } - get _isVapor(): boolean { - return `__vapor` in this._def + disconnectedCallback(): void { + this._connected = false + nextTick(() => { + if (!this._connected) { + if (this._ob) { + this._ob.disconnect() + this._ob = null + } + this._unmount() + if (this._teleportTargets) { + this._teleportTargets.clear() + this._teleportTargets = undefined + } + } + }) } protected _setParent( @@ -348,23 +365,6 @@ export abstract class VueElementBase< } } - disconnectedCallback(): void { - this._connected = false - nextTick(() => { - if (!this._connected) { - if (this._ob) { - this._ob.disconnect() - this._ob = null - } - this._unmount() - if (this._teleportTargets) { - this._teleportTargets.clear() - this._teleportTargets = undefined - } - } - }) - } - private _processMutations(mutations: MutationRecord[]) { for (const m of mutations) { this._setAttr(m.attributeName!) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index ff8638ac264..5186091b31a 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -26,8 +26,11 @@ describe('defineVaporCustomElement', () => { const container = document.createElement('div') document.body.appendChild(container) - delegateEvents('input') + beforeEach(() => { + container.innerHTML = '' + }) + delegateEvents('input') function render(tag: string, props: any) { const root = document.createElement('div') document.body.appendChild(root) @@ -42,10 +45,6 @@ describe('defineVaporCustomElement', () => { } } - beforeEach(() => { - container.innerHTML = '' - }) - describe('mounting/unmount', () => { const E = defineVaporCustomElement({ props: { @@ -163,7 +162,7 @@ describe('defineVaporCustomElement', () => { }) }) - describe('props', () => { + describe.todo('props', () => { const E = defineVaporCustomElement({ props: { foo: [String, null], diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index da4a934b064..9e70a51dbab 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -101,7 +101,24 @@ export class VaporElement extends VueElementBase< } protected _unmount(): void { - this._app!.unmount() + 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() + } this._app = this._instance = null } From cf806dbce10082271822276a0618b59bb4e6e71a Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 27 Oct 2025 22:31:55 +0800 Subject: [PATCH 07/24] wip: save --- packages/runtime-dom/src/apiCustomElement.ts | 8 +- .../__tests__/customElement.spec.ts | 312 +++++++++--------- .../src/apiDefineVaporCustomElement.ts | 16 +- packages/runtime-vapor/src/component.ts | 10 +- packages/runtime-vapor/src/componentProps.ts | 11 +- packages/runtime-vapor/src/renderEffect.ts | 2 +- 6 files changed, 191 insertions(+), 168 deletions(-) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 664872ad13c..c1132fb660e 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -285,10 +285,6 @@ export abstract class VueElementBase< } } - get _isVapor(): boolean { - return `__vapor` in this._def - } - connectedCallback(): void { // avoid resolving component if it's not connected if (!this.isConnected) return @@ -533,7 +529,7 @@ export abstract class VueElementBase< * @internal */ protected _getProp(key: string): any { - return this._isVapor ? this._props[key]() : this._props[key] + return this._props[key] } /** @@ -549,7 +545,7 @@ export abstract class VueElementBase< if (val === REMOVAL) { delete this._props[key] } else { - this._props[key] = this._isVapor ? () => val : val + this._props[key] = val // support set key on ceVNode if (key === 'key' && this._app && this._app._ceVNode) { this._app._ceVNode!.key = val diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 5186091b31a..e83bb700429 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -71,7 +71,7 @@ describe('defineVaporCustomElement', () => { }) test('should work w/ manual instantiation', () => { - const e = new E({ msg: () => 'inline' }) + const e = new E({ msg: 'inline' }) // should lazy init expect(e._instance).toBe(null) // should initialize on connect @@ -162,7 +162,7 @@ describe('defineVaporCustomElement', () => { }) }) - describe.todo('props', () => { + describe('props', () => { const E = defineVaporCustomElement({ props: { foo: [String, null], @@ -295,163 +295,177 @@ describe('defineVaporCustomElement', () => { 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, - // }, - // render() { - // return [ - // this.fooBar, - // typeof this.fooBar, - // this.bar, - // typeof this.bar, - // this.baz, - // typeof this.baz, - // ].join(' ') - // }, - // }) - // 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`, - // ) + 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('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('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`) - // }) + e.setAttribute('baz', '2e1') + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean 2e1 string`) + }) - // // #4772 - // test('attr casting w/ programmatic creation', () => { - // const E = defineVaporCustomElement({ - // props: { - // foo: Number, - // }, - // render() { - // return `foo type: ${typeof this.foo}` - // }, - // }) - // 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('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) { - // expect(props.foo).toBe('hello') - // expect(props.dataAge).toBe(5) - // }, - // render() { - // return h('div', `foo: ${this.foo}`) - // }, - // }) - // 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('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) - // test('handle properties set before connecting', () => { - // const obj = { a: 1 } - // const E = defineVaporCustomElement({ - // props: { - // foo: String, - // post: Object, - // }, - // setup(props) { - // expect(props.foo).toBe('hello') - // expect(props.post).toBe(obj) - // }, - // render() { - // return JSON.stringify(this.post) - // }, - // }) - // customElements.define('my-el-preconnect', E) - // const el = document.createElement('my-el-preconnect') as any - // el.foo = 'hello' - // el.post = obj + 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) + }) - // container.appendChild(el) - // expect(el.shadowRoot.innerHTML).toBe(JSON.stringify(obj)) - // }) + 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) - // // https://github.com/vuejs/core/issues/6163 - // test('handle components with no props', async () => { - // const E = defineVaporCustomElement({ - // render() { - // return h('div', 'foo') - // }, - // }) - // customElements.define('my-element-noprops', E) - // const el = document.createElement('my-element-noprops') - // container.appendChild(el) - // await nextTick() - // expect(el.shadowRoot!.innerHTML).toMatchInlineSnapshot('"
foo
"') - // }) + 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 - // // #5793 - // test('set number value in dom property', () => { - // const E = defineVaporCustomElement({ - // props: { - // 'max-age': Number, - // }, - // render() { - // // @ts-expect-error - // return `max age: ${this.maxAge}/type: ${typeof this.maxAge}` - // }, - // }) - // 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') - // }) + container.appendChild(el) + expect(el.shadowRoot.innerHTML).toBe(JSON.stringify(obj)) + }) - // // #9006 - // test('should reflect default value', () => { - // const E = defineVaporCustomElement({ - // props: { - // value: { - // type: String, - // default: 'hi', - // }, - // }, - // render() { - // return this.value - // }, - // }) - // customElements.define('my-el-default-val', E) - // container.innerHTML = `` - // const e = container.childNodes[0] as any - // expect(e.value).toBe('hi') - // }) + 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') + }) // // #12214 // test('Boolean prop with default true', async () => { diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index 9e70a51dbab..b9ebf35db34 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -81,7 +81,6 @@ export class VaporElement extends VueElementBase< def.name = 'VaporElement' } - def.isCE = true this._app = this._createApp(this._def) this._inheritParentContext() if (this._def.configureApp) { @@ -123,14 +122,17 @@ export class VaporElement extends VueElementBase< } private _createComponent() { - this._instance = createComponent(this._def, this._props) - if (!this.shadowRoot) { - this._instance!.m = this._instance!.u = [this._renderSlots.bind(this)] + this._def.ce = instance => { + this._instance = instance + if (!this.shadowRoot) { + ;(instance.m || (instance.m = [])).push(this._renderSlots.bind(this)) + ;(instance.u || (instance.u = [])).push(this._renderSlots.bind(this)) + } + this._processInstance() + this._setParent() } - this._processInstance() - this._setParent() - + this._instance = createComponent(this._def, this._props) return this._instance } } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 6f494ac6d6a..df116b2f73a 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -127,7 +127,10 @@ export interface ObjectVaporComponent name?: string vapor?: boolean - isCE?: boolean + /** + * @internal custom element interception hook + */ + ce?: (instance: VaporComponentInstance) => void } interface SharedInternalOptions { @@ -592,6 +595,11 @@ export class VaporComponentInstance implements GenericComponentInstance { ? new Proxy(rawSlots, dynamicSlotsProxyHandlers) : rawSlots : EMPTY_OBJ + + // apply custom element special handling + if (comp.ce) { + comp.ce(this) + } } /** diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 6832bd9103c..393e6ef2256 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](), + instance.type.ce ? rawProps[rawKey] : rawProps[rawKey](), instance, resolveDefault, ) @@ -318,7 +318,7 @@ export function setupPropsValidation(instance: VaporComponentInstance): void { renderEffect(() => { pushWarningContext(instance) validateProps( - resolveDynamicProps(rawProps), + resolveDynamicProps(rawProps, !!instance.type.ce), instance.props, normalizePropsOptions(instance.type)[0]!, ) @@ -326,11 +326,14 @@ export function setupPropsValidation(instance: VaporComponentInstance): void { }, true /* noLifecycle */) } -export function resolveDynamicProps(props: RawProps): Record { +export function resolveDynamicProps( + props: RawProps, + isResolved: boolean = false, +): Record { const mergedRawProps: Record = {} for (const key in props) { if (key !== '$') { - mergedRawProps[key] = props[key]() + mergedRawProps[key] = isResolved ? props[key] : props[key]() } } if (props.$) { diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index 8aece8ee928..e36ac4ba458 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -43,7 +43,7 @@ export class RenderEffect extends ReactiveEffect { : void 0 } - if (__DEV__ || instance.type.isCE) { + if (__DEV__ || instance.type.ce) { // register effect for stopping them during HMR rerender ;(instance.renderEffects || (instance.renderEffects = [])).push(this) } From 225428c7cb6a9b01e72482974833a86a35e1ca66 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 09:35:09 +0800 Subject: [PATCH 08/24] test: add more tests --- .../__tests__/customElement.spec.ts | 256 ++++++++++-------- packages/runtime-vapor/src/componentProps.ts | 14 +- packages/runtime-vapor/src/dom/prop.ts | 12 +- 3 files changed, 151 insertions(+), 131 deletions(-) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index e83bb700429..843b4ace8d8 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -7,11 +7,13 @@ import { toDisplayString, } from '@vue/runtime-dom' import { + child, createComponentWithFallback, createVaporApp, defineVaporComponent, defineVaporCustomElement, delegateEvents, + next, renderEffect, setInsertionState, setText, @@ -467,132 +469,152 @@ describe('defineVaporCustomElement', () => { expect(e.value).toBe('hi') }) - // // #12214 - // test('Boolean prop with default true', async () => { - // const E = defineVaporCustomElement({ - // props: { - // foo: { - // type: Boolean, - // default: true, - // }, - // }, - // render() { - // return String(this.foo) - // }, - // }) - // 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('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 => { - // return () => props.text - // }, - // { - // 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('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, - // }, - // }, - // render() { - // return h('div', [ - // h('span', [`${this.num} is ${typeof this.num}`]), - // h('span', [`${this.bool} is ${typeof this.bool}`]), - // ]) - // }, - // }) - - // customElements.define('my-el-with-type-props', E) - // render(h('my-el-with-type-props', { num: 1, bool: true }), container) - // 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
', - // ) - // }) + 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({ - // render() { - // return [h('div', null, this.$attrs.foo as string)] - // }, - // }) - // customElements.define('my-el-attrs', E) + 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
') + 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
') - // }) + 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('
') - // }) + 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 - // // eslint-disable-next-line vitest/no-disabled-tests - // test.skip('shadowRoot should be initialized with delegatesFocus', () => { - // const E = defineVaporCustomElement( - // { - // render() { - // return [h('input', { tabindex: 1 })] - // }, - // }, - // { shadowRootOptions: { delegatesFocus: true } }, - // ) - // customElements.define('my-el-with-delegate-focus', E) + // https://github.com/vuejs/core/issues/12964 + // Disabled because of missing support for `delegatesFocus` in jsdom + // https://github.com/jsdom/jsdom/issues/3418 + // test.skip('shadowRoot should be initialized with delegatesFocus', () => { + // const E = defineVaporCustomElement( + // { + // // render() { + // // return [h('input', { tabindex: 1 })] + // // }, + // setup() { + // return template('', true)() + // }, + // }, + // { shadowRootOptions: { delegatesFocus: true } }, + // ) + // customElements.define('my-el-with-delegate-focus', E) - // const e = new E() - // container.appendChild(e) - // expect(e.shadowRoot!.delegatesFocus).toBe(true) - // }) - // }) + // const e = new E() + // container.appendChild(e) + // expect(e.shadowRoot!.delegatesFocus).toBe(true) + // }) + }) // describe('emits', () => { // const CompDef = defineVaporComponent({ diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 393e6ef2256..496b001d3a2 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -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) { @@ -318,7 +319,7 @@ export function setupPropsValidation(instance: VaporComponentInstance): void { renderEffect(() => { pushWarningContext(instance) validateProps( - resolveDynamicProps(rawProps, !!instance.type.ce), + resolveDynamicProps(rawProps), instance.props, normalizePropsOptions(instance.type)[0]!, ) @@ -326,14 +327,11 @@ export function setupPropsValidation(instance: VaporComponentInstance): void { }, true /* noLifecycle */) } -export function resolveDynamicProps( - props: RawProps, - isResolved: boolean = false, -): Record { +export function resolveDynamicProps(props: RawProps): Record { const mergedRawProps: Record = {} for (const key in props) { if (key !== '$') { - mergedRawProps[key] = isResolved ? props[key] : props[key]() + mergedRawProps[key] = resolveSource(props[key]) } } if (props.$) { diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts index 7642dab9627..5aae0560aec 100644 --- a/packages/runtime-vapor/src/dom/prop.ts +++ b/packages/runtime-vapor/src/dom/prop.ts @@ -485,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( From f7290b6b42a0c1f948fa227d3fc4af55b5e6cdd4 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 10:38:19 +0800 Subject: [PATCH 09/24] wip: enhance slot rendering for custom elements --- .../__tests__/customElement.spec.ts | 354 ++++++++++-------- packages/runtime-vapor/src/componentSlots.ts | 35 +- 2 files changed, 224 insertions(+), 165 deletions(-) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 843b4ace8d8..0c4fca60183 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -9,11 +9,14 @@ import { import { child, createComponentWithFallback, + createSlot, createVaporApp, + defineVaporAsyncComponent, defineVaporComponent, defineVaporCustomElement, delegateEvents, next, + on, renderEffect, setInsertionState, setText, @@ -32,7 +35,7 @@ describe('defineVaporCustomElement', () => { container.innerHTML = '' }) - delegateEvents('input') + delegateEvents('input', 'click', 'mousedown') function render(tag: string, props: any) { const root = document.createElement('div') document.body.appendChild(root) @@ -616,169 +619,194 @@ describe('defineVaporCustomElement', () => { // }) }) - // describe('emits', () => { - // const CompDef = defineVaporComponent({ - // setup(_, { emit }) { - // emit('created') - // return () => - // h('div', { - // onClick: () => { - // emit('my-click', 1) - // }, - // onMousedown: () => { - // emit('myEvent', 1) // validate hyphenation - // }, - // onWheel: () => { - // emit('my-wheel', { bubbles: true }, 1) - // }, - // }) - // }, - // }) - // 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() - // }) + 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 interaction', () => { - // container.innerHTML = `` - // const e = container.childNodes[0] as VaporElement - // const spy = vi.fn() - // e.addEventListener('my-click', spy) - // e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click')) - // expect(spy).toHaveBeenCalledTimes(1) - // expect(spy.mock.calls[0][0]).toMatchObject({ - // detail: [1], - // }) - // }) + test('emit on connect', () => { + const e = new E() + const spy = vi.fn() + e.addEventListener('created', spy) + container.appendChild(e) + expect(spy).toHaveBeenCalled() + }) - // // #5373 - // 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')) - // expect(spy1).toHaveBeenCalledTimes(1) - // expect(spy2).toHaveBeenCalledTimes(1) - // }) + 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('emit from within async component wrapper', async () => { - // const p = new Promise(res => res(CompDef as any)) - // const E = defineVaporCustomElement(defineAsyncComponent(() => 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')) - // expect(spy).toHaveBeenCalled() - // 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) + }) - // // #7293 - // test('emit in an async component wrapper with properties bound', async () => { - // const E = defineVaporCustomElement( - // defineAsyncComponent( - // () => 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')) - // expect(spy).toHaveBeenCalled() - // expect(spy.mock.calls[0][0]).toMatchObject({ - // detail: [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 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')) - // expect(spy).toHaveBeenCalledTimes(1) - // expect(spy.mock.calls[0][0]).toMatchObject({ - // bubbles: true, - // detail: [{ bubbles: true }, 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], + }) + }) - // describe('slots', () => { - // const E = defineVaporCustomElement({ - // render() { - // return [ - // h('div', null, [ - // renderSlot(this.$slots, 'default', undefined, () => [ - // h('div', 'fallback'), - // ]), - // ]), - // h('div', null, renderSlot(this.$slots, 'named')), - // ] - // }, - // }) - // customElements.define('my-el-slots', E) + 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], + }) + }) + }) - // 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
`, - // ) - // }) + 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 slot props', async () => { - // const foo = ref('foo') - // const E = defineVaporCustomElement({ - // render() { - // return [ - // h( - // 'div', - // null, - // renderSlot(this.$slots, 'default', { class: foo.value }), - // ), - // ] - // }, - // }) - // customElements.define('my-el-slot-props', E) - // container.innerHTML = `hi` - // const e = container.childNodes[0] as VaporElement - // expect(e.shadowRoot!.innerHTML).toBe( - // `
`, - // ) + 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
` + + `
` + + `
` + + `` + + `
`, + ) + }) - // foo.value = 'bar' - // await nextTick() - // expect(e.shadowRoot!.innerHTML).toBe( - // `
`, - // ) - // }) - // }) + 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({ @@ -1102,7 +1130,7 @@ describe('defineVaporCustomElement', () => { // test('should work', async () => { // const loaderSpy = vi.fn() // const E = defineVaporCustomElement( - // defineAsyncComponent(() => { + // defineVaporAsyncComponent(() => { // loaderSpy() // return Promise.resolve({ // props: ['msg'], @@ -1153,7 +1181,7 @@ describe('defineVaporCustomElement', () => { // test('set DOM property before resolve', async () => { // const E = defineVaporCustomElement( - // defineAsyncComponent(() => { + // defineVaporAsyncComponent(() => { // return Promise.resolve({ // props: ['msg'], // setup(props) { @@ -1194,7 +1222,7 @@ describe('defineVaporCustomElement', () => { // test('Number prop casting before resolve', async () => { // const E = defineVaporCustomElement( - // defineAsyncComponent(() => { + // defineVaporAsyncComponent(() => { // return Promise.resolve({ // props: { n: Number }, // setup(props) { @@ -1217,7 +1245,7 @@ describe('defineVaporCustomElement', () => { // test('with slots', async () => { // const E = defineVaporCustomElement( - // defineAsyncComponent(() => { + // defineVaporAsyncComponent(() => { // return Promise.resolve({ // render(this: any) { // return [ @@ -1709,7 +1737,7 @@ describe('defineVaporCustomElement', () => { // test('async & nested custom elements', async () => { // let fooVal: string | undefined = '' // const E = defineVaporCustomElement( - // defineAsyncComponent(() => { + // defineVaporAsyncComponent(() => { // return Promise.resolve({ // setup(props) { // provide('foo', 'foo') @@ -1743,7 +1771,7 @@ describe('defineVaporCustomElement', () => { // let fooVal: string | undefined = '' // let barVal: string | undefined = '' // const E = defineVaporCustomElement( - // defineAsyncComponent(() => { + // defineVaporAsyncComponent(() => { // return Promise.resolve({ // setup(props) { // provide('foo', 'foo') @@ -1813,7 +1841,7 @@ describe('defineVaporCustomElement', () => { // // #12448 // test('work with async component', async () => { - // const AsyncComp = defineAsyncComponent(() => { + // const AsyncComp = defineVaporAsyncComponent(() => { // return Promise.resolve({ // render() { // const msg: string | undefined = inject('msg') @@ -1904,7 +1932,7 @@ describe('defineVaporCustomElement', () => { // // #11081 // test('Props can be casted when mounting custom elements in component rendering functions', async () => { // const E = defineVaporCustomElement( - // defineAsyncComponent(() => + // defineVaporAsyncComponent(() => // Promise.resolve({ // props: ['fooValue'], // setup(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 From 7e29657e3074542665445f393d66bf0eb26610c1 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 11:52:06 +0800 Subject: [PATCH 10/24] wip: save --- packages/runtime-dom/src/apiCustomElement.ts | 3 +- .../__tests__/customElement.spec.ts | 369 +++++++++--------- .../src/apiDefineVaporCustomElement.ts | 15 +- 3 files changed, 199 insertions(+), 188 deletions(-) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index c1132fb660e..ede09871546 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -488,6 +488,8 @@ export abstract class VueElementBase< dispatch(hyphenate(event), args) } } + + this._setParent() } private _resolveProps(def: Def): void { @@ -762,7 +764,6 @@ export class VueElement extends VueElementBase< vnode.ce = instance => { this._instance = instance this._processInstance() - this._setParent() } } return vnode diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 0c4fca60183..6a724ba27fc 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -2,7 +2,10 @@ import type { VaporElement } from '../src/apiDefineVaporCustomElement' import { type HMRRuntime, + type Ref, + inject, nextTick, + provide, ref, toDisplayString, } from '@vue/runtime-dom' @@ -808,200 +811,208 @@ describe('defineVaporCustomElement', () => { }) }) - // describe('provide/inject', () => { - // const Consumer = defineVaporCustomElement({ - // setup() { - // const foo = inject('foo')! - // return () => h('div', foo.value) - // }, - // }) - // customElements.define('my-consumer', Consumer) - - // test('over nested usage', async () => { - // const foo = ref('injected!') - // const Provider = defineVaporCustomElement({ - // provide: { - // foo, - // }, - // render() { - // return h('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({ - // provide: { - // foo, - // }, - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }) - // customElements.define('my-provider-2', Provider) + describe.todo('provide/inject', () => { + const Consumer = defineVaporCustomElement({ + setup() { + const foo = inject('foo')! + // return () => h('div', foo.value) + 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({ + // provide: { + // foo, + // }, + // render() { + // return h('my-consumer') + // }, + setup() { + provide('foo', foo) + return createComponentWithFallback('my-consumer') + }, + }) + customElements.define('my-provider', Provider) + container.innerHTML = `` + const provider = container.childNodes[0] as VaporElement + const consumer = provider.shadowRoot!.childNodes[0] as VaporElement - // container.innerHTML = `` - // const provider = container.childNodes[0] - // const consumer = provider.childNodes[0] as VaporElement - // expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) + expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) - // foo.value = 'changed!' - // await nextTick() - // expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) - // }) + 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({ - // provide: { - // fooA, - // }, - // render() { - // return h('provider-b') - // }, - // }) - // const ProviderB = defineVaporCustomElement({ - // provide: { - // fooB, - // }, - // render() { - // return h('my-multi-consumer') - // }, - // }) + // test('over slot composition', async () => { + // const foo = ref('injected!') + // const Provider = defineVaporCustomElement({ + // provide: { + // foo, + // }, + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }) + // customElements.define('my-provider-2', Provider) - // const Consumer = defineVaporCustomElement({ - // setup() { - // const fooA = inject('fooA')! - // const fooB = inject('fooB')! - // return () => h('div', `${fooA.value} ${fooB.value}`) - // }, - // }) + // container.innerHTML = `` + // const provider = container.childNodes[0] + // const consumer = provider.childNodes[0] as VaporElement + // expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) - // 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 + // foo.value = 'changed!' + // await nextTick() + // expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) + // }) - // expect(consumer.shadowRoot!.innerHTML).toBe(`
FooA! FooB!
`) + // test('inherited from ancestors', async () => { + // const fooA = ref('FooA!') + // const fooB = ref('FooB!') + // const ProviderA = defineVaporCustomElement({ + // provide: { + // fooA, + // }, + // render() { + // return h('provider-b') + // }, + // }) + // const ProviderB = defineVaporCustomElement({ + // provide: { + // fooB, + // }, + // render() { + // return h('my-multi-consumer') + // }, + // }) - // fooA.value = 'changedA!' - // fooB.value = 'changedB!' - // await nextTick() - // expect(consumer.shadowRoot!.innerHTML).toBe( - // `
changedA! changedB!
`, - // ) - // }) + // const Consumer = defineVaporCustomElement({ + // setup() { + // const fooA = inject('fooA')! + // const fooB = inject('fooB')! + // return () => h('div', `${fooA.value} ${fooB.value}`) + // }, + // }) + + // 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!
`, + // ) + // }) - // // #13212 - // test('inherited from app context within nested elements', async () => { - // const outerValues: (string | undefined)[] = [] - // const innerValues: (string | undefined)[] = [] - // const innerChildValues: (string | undefined)[] = [] + // // #13212 + // 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'), - // ) - // }, - // render() { - // return h('div', [renderSlot(this.$slots, 'default')]) - // }, - // }, - // { - // configureApp(app) { - // app.provide('shared', 'shared') - // app.provide('outer', 'outer') - // }, - // }, - // ) - - // const Inner = defineVaporCustomElement( - // { - // setup() { - // // ensure values are not self-injected - // provide('inner', 'inner-child') + // const Outer = defineVaporCustomElement( + // { + // setup() { + // outerValues.push( + // inject('shared'), + // inject('outer'), + // inject('inner'), + // ) + // }, + // render() { + // return h('div', [renderSlot(this.$slots, 'default')]) + // }, + // }, + // { + // configureApp(app) { + // app.provide('shared', 'shared') + // app.provide('outer', 'outer') + // }, + // }, + // ) - // innerValues.push( - // inject('shared'), - // inject('outer'), - // inject('inner'), - // ) - // }, - // render() { - // return h('div', [renderSlot(this.$slots, 'default')]) - // }, - // }, - // { - // configureApp(app) { - // app.provide('outer', 'override-outer') - // app.provide('inner', 'inner') - // }, - // }, - // ) + // const Inner = defineVaporCustomElement( + // { + // setup() { + // // ensure values are not self-injected + // provide('inner', 'inner-child') + + // innerValues.push( + // inject('shared'), + // inject('outer'), + // inject('inner'), + // ) + // }, + // render() { + // return h('div', [renderSlot(this.$slots, 'default')]) + // }, + // }, + // { + // configureApp(app) { + // app.provide('outer', 'override-outer') + // app.provide('inner', 'inner') + // }, + // }, + // ) - // const InnerChild = defineVaporCustomElement({ - // setup() { - // innerChildValues.push( - // inject('shared'), - // inject('outer'), - // inject('inner'), - // ) - // }, - // render() { - // return h('div') - // }, - // }) + // const InnerChild = defineVaporCustomElement({ + // setup() { + // innerChildValues.push( + // inject('shared'), + // inject('outer'), + // inject('inner'), + // ) + // }, + // render() { + // return h('div') + // }, + // }) - // customElements.define('provide-from-app-outer', Outer) - // customElements.define('provide-from-app-inner', Inner) - // customElements.define('provide-from-app-inner-child', InnerChild) + // customElements.define('provide-from-app-outer', Outer) + // customElements.define('provide-from-app-inner', Inner) + // customElements.define('provide-from-app-inner-child', InnerChild) - // container.innerHTML = - // '' + - // '' + - // '' + - // '' + - // '' + // container.innerHTML = + // '' + + // '' + + // '' + + // '' + + // '' - // const outer = container.childNodes[0] as VaporElement - // expect(outer.shadowRoot!.innerHTML).toBe('
') + // 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', - // ]) - // }) - // }) + // 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[]) { diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index b9ebf35db34..9f8aba47e66 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -87,7 +87,7 @@ export class VaporElement extends VueElementBase< this._def.configureApp(this._app) } - this._app._ceComponent = this._createComponent() + this._createComponent() this._app!.mount(this._root) } @@ -118,21 +118,20 @@ export class VaporElement extends VueElementBase< } else { this._app!.unmount() } + if (this._instance && this._instance.ce) { + this._instance.ce = undefined + } this._app = this._instance = null } private _createComponent() { this._def.ce = instance => { - this._instance = instance + this._app!._ceComponent = this._instance = instance if (!this.shadowRoot) { - ;(instance.m || (instance.m = [])).push(this._renderSlots.bind(this)) - ;(instance.u || (instance.u = [])).push(this._renderSlots.bind(this)) + this._instance!.m = this._instance!.u = [this._renderSlots.bind(this)] } this._processInstance() - this._setParent() } - - this._instance = createComponent(this._def, this._props) - return this._instance + createComponent(this._def, this._props) } } From 9fa0d6310fbd58e45f80a1009cde16fb38d0655b Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 14:15:19 +0800 Subject: [PATCH 11/24] fix: update createComponent call to include app context --- .../__tests__/customElement.spec.ts | 309 +++++++++--------- .../src/apiDefineVaporCustomElement.ts | 10 +- 2 files changed, 160 insertions(+), 159 deletions(-) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 6a724ba27fc..628e0fa0a43 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -811,11 +811,10 @@ describe('defineVaporCustomElement', () => { }) }) - describe.todo('provide/inject', () => { + describe('provide/inject', () => { const Consumer = defineVaporCustomElement({ setup() { const foo = inject('foo')! - // return () => h('div', foo.value) const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, toDisplayString(foo.value))) @@ -827,12 +826,6 @@ describe('defineVaporCustomElement', () => { test('over nested usage', async () => { const foo = ref('injected!') const Provider = defineVaporCustomElement({ - // provide: { - // foo, - // }, - // render() { - // return h('my-consumer') - // }, setup() { provide('foo', foo) return createComponentWithFallback('my-consumer') @@ -850,168 +843,168 @@ describe('defineVaporCustomElement', () => { expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) }) - // test('over slot composition', async () => { - // const foo = ref('injected!') - // const Provider = defineVaporCustomElement({ - // provide: { - // foo, - // }, - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }) - // customElements.define('my-provider-2', Provider) + 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!
`) + 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!
`) - // }) + 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({ - // provide: { - // fooA, - // }, - // render() { - // return h('provider-b') - // }, - // }) - // const ProviderB = defineVaporCustomElement({ - // provide: { - // fooB, - // }, - // render() { - // return h('my-multi-consumer') - // }, - // }) + test('inherited from ancestors', async () => { + const fooA = ref('FooA!') + const fooB = ref('FooB!') + const ProviderA = defineVaporCustomElement({ + setup() { + provide('fooA', fooA) + return createComponentWithFallback('provider-b') + }, + }) + const ProviderB = defineVaporCustomElement({ + setup() { + provide('fooB', fooB) + return createComponentWithFallback('my-multi-consumer') + }, + }) - // const Consumer = defineVaporCustomElement({ - // setup() { - // const fooA = inject('fooA')! - // const fooB = inject('fooB')! - // return () => h('div', `${fooA.value} ${fooB.value}`) - // }, - // }) - - // 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!
`, - // ) - // }) + 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 + }, + }) - // // #13212 - // test('inherited from app context within nested elements', async () => { - // const outerValues: (string | undefined)[] = [] - // const innerValues: (string | undefined)[] = [] - // const innerChildValues: (string | undefined)[] = [] + 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 - // const Outer = defineVaporCustomElement( - // { - // setup() { - // outerValues.push( - // inject('shared'), - // inject('outer'), - // inject('inner'), - // ) - // }, - // render() { - // return h('div', [renderSlot(this.$slots, 'default')]) - // }, - // }, - // { - // configureApp(app) { - // app.provide('shared', 'shared') - // app.provide('outer', 'outer') - // }, - // }, - // ) + expect(consumer.shadowRoot!.innerHTML).toBe(`
FooA! FooB!
`) - // const Inner = defineVaporCustomElement( - // { - // setup() { - // // ensure values are not self-injected - // provide('inner', 'inner-child') - - // innerValues.push( - // inject('shared'), - // inject('outer'), - // inject('inner'), - // ) - // }, - // render() { - // return h('div', [renderSlot(this.$slots, 'default')]) - // }, - // }, - // { - // configureApp(app) { - // app.provide('outer', 'override-outer') - // app.provide('inner', 'inner') - // }, - // }, - // ) + fooA.value = 'changedA!' + fooB.value = 'changedB!' + await nextTick() + expect(consumer.shadowRoot!.innerHTML).toBe( + `
changedA! changedB!
`, + ) + }) - // const InnerChild = defineVaporCustomElement({ - // setup() { - // innerChildValues.push( - // inject('shared'), - // inject('outer'), - // inject('inner'), - // ) - // }, - // render() { - // return h('div') - // }, - // }) + test('inherited from app context within nested elements', async () => { + const outerValues: (string | undefined)[] = [] + const innerValues: (string | undefined)[] = [] + const innerChildValues: (string | undefined)[] = [] + + const Outer = defineVaporCustomElement( + { + setup() { + outerValues.push( + inject('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, + ) - // customElements.define('provide-from-app-outer', Outer) - // customElements.define('provide-from-app-inner', Inner) - // customElements.define('provide-from-app-inner-child', InnerChild) + 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, + ) - // container.innerHTML = - // '' + - // '' + - // '' + - // '' + - // '' + const InnerChild = defineVaporCustomElement({ + setup() { + innerChildValues.push( + inject('shared'), + inject('outer'), + inject('inner'), + ) + const n0 = template('
', true)() as any + return n0 + }, + }) - // const outer = container.childNodes[0] as VaporElement - // expect(outer.shadowRoot!.innerHTML).toBe('
') + customElements.define('provide-from-app-outer', Outer) + customElements.define('provide-from-app-inner', Inner) + customElements.define('provide-from-app-inner-child', InnerChild) - // 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', - // ]) - // }) + 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', () => { diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index 9f8aba47e66..6cf9b5f67aa 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -132,6 +132,14 @@ export class VaporElement extends VueElementBase< } this._processInstance() } - createComponent(this._def, this._props) + + createComponent( + this._def, + this._props, + undefined, + undefined, + undefined, + this._app!._context, + ) } } From a590cfc4b5621dae425daceb830a90566204043f Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 15:59:58 +0800 Subject: [PATCH 12/24] fix: enhance HMR style handling and component style injection --- packages/runtime-core/src/hmr.ts | 11 +- packages/runtime-dom/src/apiCustomElement.ts | 4 +- .../__tests__/customElement.spec.ts | 227 +++++++++--------- packages/runtime-vapor/src/component.ts | 12 + 4 files changed, 139 insertions(+), 115 deletions(-) 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-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index ede09871546..341ed48e3df 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -466,7 +466,9 @@ export abstract class VueElementBase< this._styles.length = 0 } this._applyStyles(newStyles) - this._instance = null + if (!this._instance!.vapor) { + this._instance = null + } this._update() } } diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 628e0fa0a43..6072da34a24 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -11,6 +11,7 @@ import { } from '@vue/runtime-dom' import { child, + createComponent, createComponentWithFallback, createSlot, createVaporApp, @@ -1007,128 +1008,128 @@ describe('defineVaporCustomElement', () => { }) }) - // 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; }`], - // render() { - // return h('div', 'hello') - // }, - // }) - // 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) + 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]) + } + } - // await nextTick() - // assertStyles(el, [`div { color: blue; }`, `div { color: yellow; }`]) - // }) + 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) - // test("child components should inject styles to root element's shadow root", async () => { - // const Baz = () => h(Bar) - // const Bar = defineVaporComponent({ - // __hmrId: 'bar', - // styles: [`div { color: green; }`, `div { color: blue; }`], - // render() { - // return 'bar' - // }, - // }) - // const Foo = defineVaporCustomElement({ - // styles: [`div { color: red; }`], - // render() { - // return [h(Baz), h(Baz)] - // }, - // }) - // customElements.define('my-el-with-child-styles', Foo) - // container.innerHTML = `` - // const el = container.childNodes[0] as VaporElement + await nextTick() + assertStyles(el, [`div { color: blue; }`, `div { color: yellow; }`]) + }) - // // inject order should be child -> parent - // assertStyles(el, [ - // `div { color: green; }`, - // `div { color: blue; }`, - // `div { color: red; }`, - // ]) + 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) + // 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; }`, - // ]) + 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; }`]) - // }) + __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; }`], - // render() { - // return 'bar' - // }, - // }) - // const Baz = () => h(Bar) - // const Foo = defineVaporCustomElement( - // { - // render() { - // return [h(Baz)] - // }, - // }, - // { shadowRoot: false }, - // ) + // test("child components should not inject styles to root element's shadow root w/ shadowRoot false", async () => { + // const Bar = defineVaporComponent({ + // styles: [`div { color: green; }`], + // render() { + // return 'bar' + // }, + // }) + // const Baz = () => h(Bar) + // const Foo = defineVaporCustomElement( + // { + // render() { + // return [h(Baz)] + // }, + // }, + // { shadowRoot: false }, + // ) - // 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() - // }) + // 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; }`], - // render() { - // return h('div', 'hello') - // }, - // }, - // { nonce: 'xxx' }, - // ) - // 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') - // }) - // }) + // test('with nonce', () => { + // const Foo = defineVaporCustomElement( + // { + // styles: [`div { color: red; }`], + // render() { + // return h('div', 'hello') + // }, + // }, + // { nonce: 'xxx' }, + // ) + // 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 () => { diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index df116b2f73a..8073237b427 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -94,6 +94,7 @@ import { resetInsertionState, } from './insertionState' import { DynamicFragment } from './fragment' +import type { VaporElement } from './apiDefineVaporCustomElement' export { currentInstance } from '@vue/runtime-dom' @@ -706,6 +707,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`) } From f6eaa8879c34e65672bf4005fd412a69fe7c75d7 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 16:02:07 +0800 Subject: [PATCH 13/24] wip: save --- .../__tests__/customElement.spec.ts | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 6072da34a24..754f2b8aece 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -1089,46 +1089,46 @@ describe('defineVaporCustomElement', () => { 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; }`], - // render() { - // return 'bar' - // }, - // }) - // const Baz = () => h(Bar) - // const Foo = defineVaporCustomElement( - // { - // render() { - // return [h(Baz)] - // }, - // }, - // { shadowRoot: false }, - // ) + 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() - // }) + 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; }`], - // render() { - // return h('div', 'hello') - // }, - // }, - // { nonce: 'xxx' }, - // ) - // 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') - // }) + 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', () => { From 4edb144fbd827c87fe1ee6beb2708a9520041644 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 16:37:30 +0800 Subject: [PATCH 14/24] wip: save --- .../__tests__/customElement.spec.ts | 917 +++++++++--------- 1 file changed, 468 insertions(+), 449 deletions(-) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 754f2b8aece..894d4976391 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -13,6 +13,7 @@ import { child, createComponent, createComponentWithFallback, + createIf, createSlot, createVaporApp, defineVaporAsyncComponent, @@ -1131,492 +1132,510 @@ describe('defineVaporCustomElement', () => { }) }) - // describe('async', () => { - // test('should work', async () => { - // const loaderSpy = vi.fn() - // const E = defineVaporCustomElement( - // defineVaporAsyncComponent(() => { - // loaderSpy() - // return Promise.resolve({ - // props: ['msg'], - // styles: [`div { color: red }`], - // render(this: any) { - // return h('div', null, this.msg) - // }, - // }) - // }), - // ) - // customElements.define('my-el-async', E) - // container.innerHTML = - // `` + - // `` - - // await new Promise(r => setTimeout(r)) - - // // loader should be called only once - // expect(loaderSpy).toHaveBeenCalledTimes(1) + 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 = + `` + + `` - // const e1 = container.childNodes[0] as VaporElement - // const e2 = container.childNodes[1] as VaporElement + await new Promise(r => setTimeout(r)) - // // should inject styles - // expect(e1.shadowRoot!.innerHTML).toBe( - // `
hello
`, - // ) - // expect(e2.shadowRoot!.innerHTML).toBe( - // `
world
`, - // ) + // loader should be called only once + expect(loaderSpy).toHaveBeenCalledTimes(1) - // // attr - // e1.setAttribute('msg', 'attr') - // await nextTick() - // expect((e1 as any).msg).toBe('attr') - // expect(e1.shadowRoot!.innerHTML).toBe( - // `
attr
`, - // ) + const e1 = container.childNodes[0] as VaporElement + const e2 = container.childNodes[1] as VaporElement - // // 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) { - // expect(typeof props.msg).toBe('string') - // }, - // render(this: any) { - // return h('div', this.msg) - // }, - // }) - // }), - // ) - // customElements.define('my-el-async-2', E) - - // const e1 = new E() + // should inject styles + expect(e1.shadowRoot!.innerHTML).toBe( + `
hello
`, + ) + expect(e2.shadowRoot!.innerHTML).toBe( + `
world
`, + ) - // // set property before connect - // e1.msg = 'hello' + // attr + e1.setAttribute('msg', 'attr') + await nextTick() + expect((e1 as any).msg).toBe('attr') + expect(e1.shadowRoot!.innerHTML).toBe( + `
attr
`, + ) - // const e2 = new E() + // props + expect(`msg` in e1).toBe(true) + ;(e1 as any).msg = 'prop' + expect(e1.getAttribute('msg')).toBe('prop') + expect(e1.shadowRoot!.innerHTML).toBe( + `
prop
`, + ) + }) - // container.appendChild(e1) - // container.appendChild(e2) + 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) - // // set property after connect but before resolve - // e2.msg = 'world' + const e1 = new E() as any - // await new Promise(r => setTimeout(r)) + // set property before connect + e1.msg = 'hello' - // expect(e1.shadowRoot!.innerHTML).toBe(`
hello
`) - // expect(e2.shadowRoot!.innerHTML).toBe(`
world
`) + const e2 = new E() as any - // e1.msg = 'world' - // expect(e1.shadowRoot!.innerHTML).toBe(`
world
`) + container.appendChild(e1) + container.appendChild(e2) - // e2.msg = 'hello' - // expect(e2.shadowRoot!.innerHTML).toBe(`
hello
`) - // }) + // set property after connect but before resolve + e2.msg = 'world' - // test('Number prop casting before resolve', async () => { - // const E = defineVaporCustomElement( - // defineVaporAsyncComponent(() => { - // return Promise.resolve({ - // props: { n: Number }, - // setup(props) { - // expect(props.n).toBe(20) - // }, - // render(this: any) { - // return h('div', this.n + ',' + typeof this.n) - // }, - // }) - // }), - // ) - // customElements.define('my-el-async-3', E) - // container.innerHTML = `` + await new Promise(r => setTimeout(r)) - // await new Promise(r => setTimeout(r)) + expect(e1.shadowRoot!.innerHTML).toBe(`
hello
`) + expect(e2.shadowRoot!.innerHTML).toBe(`
world
`) - // const e = container.childNodes[0] as VaporElement - // expect(e.shadowRoot!.innerHTML).toBe(`
20,number
`) - // }) + e1.msg = 'world' + expect(e1.shadowRoot!.innerHTML).toBe(`
world
`) - // test('with slots', async () => { - // const E = defineVaporCustomElement( - // defineVaporAsyncComponent(() => { - // return Promise.resolve({ - // render(this: any) { - // return [ - // h('div', null, [ - // renderSlot(this.$slots, 'default', undefined, () => [ - // h('div', 'fallback'), - // ]), - // ]), - // h('div', null, renderSlot(this.$slots, 'named')), - // ] - // }, - // }) - // }), - // ) - // customElements.define('my-el-async-slots', E) - // container.innerHTML = `hi` + e2.msg = 'hello' + expect(e2.shadowRoot!.innerHTML).toBe(`
hello
`) + }) - // await new Promise(r => setTimeout(r)) + 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 = `` - // const e = container.childNodes[0] as VaporElement - // expect(e.shadowRoot!.innerHTML).toBe( - // `
fallback
`, - // ) - // }) - // }) + await new Promise(r => setTimeout(r)) - // describe('shadowRoot: false', () => { - // const E = defineVaporCustomElement({ - // shadowRoot: false, - // props: { - // msg: { - // type: String, - // default: 'hello', - // }, - // }, - // render() { - // return h('div', this.msg) - // }, - // }) - // customElements.define('my-el-shadowroot-false', E) + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe(`
20,number
`) + }) - // test('should work', async () => { - // function raf() { - // return new Promise(resolve => { - // requestAnimationFrame(resolve) - // }) - // } + 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` - // 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) - // }) + await new Promise(r => setTimeout(r)) - // const toggle = ref(true) - // const ES = defineVaporCustomElement( - // { - // render() { - // return [ - // renderSlot(this.$slots, 'default'), - // toggle.value ? renderSlot(this.$slots, 'named') : null, - // renderSlot(this.$slots, 'omitted', {}, () => [ - // h('div', 'fallback'), - // ]), - // ] - // }, - // }, - // { shadowRoot: false }, - // ) - // 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
`, - // ) - // }) + const e = container.childNodes[0] as VaporElement + expect(e.shadowRoot!.innerHTML).toBe( + `
` + + `
fallback
` + + `
` + + `` + + `
`, + ) + }) + }) - // test('render nested customElement w/ shadowRoot false', async () => { - // const calls: string[] = [] + 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) - // const Child = defineVaporCustomElement( - // { - // setup() { - // calls.push('child rendering') - // onMounted(() => { - // calls.push('child mounted') - // }) - // }, - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-child', Child) + test('should work', async () => { + function raf() { + return new Promise(resolve => { + requestAnimationFrame(resolve) + }) + } - // const Parent = defineVaporCustomElement( - // { - // setup() { - // calls.push('parent rendering') - // onMounted(() => { - // calls.push('parent mounted') - // }) - // }, - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-parent', Parent) + 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 App = { - // render() { - // return h('my-parent', null, { - // default: () => [ - // h('my-child', null, { - // default: () => [h('span', null, '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() - // }) + 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('render nested Teleport w/ shadowRoot false', async () => { - // const target = document.createElement('div') - // const Child = defineVaporCustomElement( - // { - // render() { - // return h( - // Teleport, - // { to: target }, - // { - // default: () => [renderSlot(this.$slots, 'default')], - // }, - // ) - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-teleport-child', Child) - // const Parent = defineVaporCustomElement( - // { - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-teleport-parent', Parent) + test.todo('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
`, + ) - // const App = { - // render() { - // return h('my-el-teleport-parent', null, { - // default: () => [ - // h('my-el-teleport-child', null, { - // default: () => [h('span', null, 'default')], - // }), - // ], - // }) - // }, - // } - // const app = createVaporApp(App) - // app.mount(container) - // await nextTick() - // expect(target.innerHTML).toBe(`default`) - // app.unmount() - // }) + toggle.value = false + await nextTick() + expect(e.innerHTML).toBe( + `defaulttext` + `` + `
fallback
`, + ) + }) - // test('render two Teleports w/ shadowRoot false', async () => { - // const target1 = document.createElement('div') - // const target2 = document.createElement('span') - // const Child = defineVaporCustomElement( - // { - // render() { - // return [ - // h(Teleport, { to: target1 }, [renderSlot(this.$slots, 'header')]), - // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), - // ] - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-two-teleport-child', Child) + // test('render nested customElement w/ shadowRoot false', async () => { + // const calls: string[] = [] - // const App = { - // render() { - // return h('my-el-two-teleport-child', null, { - // default: () => [ - // h('div', { slot: 'header' }, 'header'), - // h('span', { slot: 'body' }, 'body'), - // ], - // }) - // }, - // } - // const app = createVaporApp(App) - // app.mount(container) - // await nextTick() - // expect(target1.outerHTML).toBe( - // `
header
`, - // ) - // expect(target2.outerHTML).toBe( - // `body`, - // ) - // app.unmount() - // }) + // const Child = defineVaporCustomElement( + // { + // setup() { + // calls.push('child rendering') + // onMounted(() => { + // calls.push('child mounted') + // }) + // }, + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-child', Child) - // test('render two Teleports w/ shadowRoot false (with disabled)', async () => { - // const target1 = document.createElement('div') - // const target2 = document.createElement('span') - // const Child = defineVaporCustomElement( - // { - // render() { - // return [ - // // with disabled: true - // h(Teleport, { to: target1, disabled: true }, [ - // renderSlot(this.$slots, 'header'), - // ]), - // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), - // ] - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-two-teleport-child-0', Child) + // const Parent = defineVaporCustomElement( + // { + // setup() { + // calls.push('parent rendering') + // onMounted(() => { + // calls.push('parent mounted') + // }) + // }, + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-parent', Parent) + + // const App = { + // render() { + // return h('my-parent', null, { + // default: () => [ + // h('my-child', null, { + // default: () => [h('span', null, '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() + // }) - // const App = { - // render() { - // return h('my-el-two-teleport-child-0', null, { - // default: () => [ - // h('div', { slot: 'header' }, 'header'), - // h('span', { slot: 'body' }, 'body'), - // ], - // }) - // }, - // } - // const app = createVaporApp(App) - // app.mount(container) - // await nextTick() - // expect(target1.outerHTML).toBe(`
`) - // expect(target2.outerHTML).toBe( - // `body`, - // ) - // app.unmount() - // }) + // test('render nested Teleport w/ shadowRoot false', async () => { + // const target = document.createElement('div') + // const Child = defineVaporCustomElement( + // { + // render() { + // return h( + // Teleport, + // { to: target }, + // { + // default: () => [renderSlot(this.$slots, 'default')], + // }, + // ) + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-teleport-child', Child) + // const Parent = defineVaporCustomElement( + // { + // render() { + // return renderSlot(this.$slots, 'default') + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-teleport-parent', Parent) + + // const App = { + // render() { + // return h('my-el-teleport-parent', null, { + // default: () => [ + // h('my-el-teleport-child', null, { + // default: () => [h('span', null, 'default')], + // }), + // ], + // }) + // }, + // } + // const app = createVaporApp(App) + // app.mount(container) + // await nextTick() + // expect(target.innerHTML).toBe(`default`) + // app.unmount() + // }) - // test('toggle nested custom element with shadowRoot: false', async () => { - // customElements.define( - // 'my-el-child-shadow-false', - // defineVaporCustomElement( - // { - // render(ctx: any) { - // return h('div', null, [renderSlot(ctx.$slots, 'default')]) - // }, - // }, - // { shadowRoot: false }, - // ), - // ) - // const ChildWrapper = { - // render() { - // return h('my-el-child-shadow-false', null, 'child') - // }, - // } + // test('render two Teleports w/ shadowRoot false', async () => { + // const target1 = document.createElement('div') + // const target2 = document.createElement('span') + // const Child = defineVaporCustomElement( + // { + // render() { + // return [ + // h(Teleport, { to: target1 }, [renderSlot(this.$slots, 'header')]), + // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), + // ] + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-two-teleport-child', Child) + + // const App = { + // render() { + // return h('my-el-two-teleport-child', null, { + // default: () => [ + // h('div', { slot: 'header' }, 'header'), + // h('span', { slot: 'body' }, 'body'), + // ], + // }) + // }, + // } + // const app = createVaporApp(App) + // app.mount(container) + // await nextTick() + // expect(target1.outerHTML).toBe( + // `
header
`, + // ) + // expect(target2.outerHTML).toBe( + // `body`, + // ) + // app.unmount() + // }) - // customElements.define( - // 'my-el-parent-shadow-false', - // defineVaporCustomElement( - // { - // props: { - // isShown: { type: Boolean, required: true }, - // }, - // render(ctx: any, _: any, $props: any) { - // return $props.isShown - // ? h('div', { key: 0 }, [renderSlot(ctx.$slots, 'default')]) - // : null - // }, - // }, - // { shadowRoot: false }, - // ), - // ) - // const ParentWrapper = { - // props: { - // isShown: { type: Boolean, required: true }, - // }, - // render(ctx: any, _: any, $props: any) { - // return h('my-el-parent-shadow-false', { isShown: $props.isShown }, [ - // renderSlot(ctx.$slots, 'default'), - // ]) - // }, - // } + // test('render two Teleports w/ shadowRoot false (with disabled)', async () => { + // const target1 = document.createElement('div') + // const target2 = document.createElement('span') + // const Child = defineVaporCustomElement( + // { + // render() { + // return [ + // // with disabled: true + // h(Teleport, { to: target1, disabled: true }, [ + // renderSlot(this.$slots, 'header'), + // ]), + // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), + // ] + // }, + // }, + // { shadowRoot: false }, + // ) + // customElements.define('my-el-two-teleport-child-0', Child) + + // const App = { + // render() { + // return h('my-el-two-teleport-child-0', null, { + // default: () => [ + // h('div', { slot: 'header' }, 'header'), + // h('span', { slot: 'body' }, 'body'), + // ], + // }) + // }, + // } + // const app = createVaporApp(App) + // app.mount(container) + // await nextTick() + // expect(target1.outerHTML).toBe(`
`) + // expect(target2.outerHTML).toBe( + // `body`, + // ) + // app.unmount() + // }) - // const isShown = ref(true) - // const App = { - // render() { - // return h(ParentWrapper, { isShown: isShown.value } as any, { - // default: () => [h(ChildWrapper)], - // }) - // }, - // } - // const container = document.createElement('div') - // document.body.appendChild(container) - // const app = createVaporApp(App) - // app.mount(container) - // expect(container.innerHTML).toBe( - // `` + - // `
` + - // `` + - // `
child
` + - // `
` + - // `
` + - // `
`, - // ) + // test('toggle nested custom element with shadowRoot: false', async () => { + // customElements.define( + // 'my-el-child-shadow-false', + // defineVaporCustomElement( + // { + // render(ctx: any) { + // return h('div', null, [renderSlot(ctx.$slots, 'default')]) + // }, + // }, + // { shadowRoot: false }, + // ), + // ) + // const ChildWrapper = { + // render() { + // return h('my-el-child-shadow-false', null, 'child') + // }, + // } + + // customElements.define( + // 'my-el-parent-shadow-false', + // defineVaporCustomElement( + // { + // props: { + // isShown: { type: Boolean, required: true }, + // }, + // render(ctx: any, _: any, $props: any) { + // return $props.isShown + // ? h('div', { key: 0 }, [renderSlot(ctx.$slots, 'default')]) + // : null + // }, + // }, + // { shadowRoot: false }, + // ), + // ) + // const ParentWrapper = { + // props: { + // isShown: { type: Boolean, required: true }, + // }, + // render(ctx: any, _: any, $props: any) { + // return h('my-el-parent-shadow-false', { isShown: $props.isShown }, [ + // renderSlot(ctx.$slots, 'default'), + // ]) + // }, + // } + + // const isShown = ref(true) + // const App = { + // render() { + // return h(ParentWrapper, { isShown: isShown.value } as any, { + // default: () => [h(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 = false + // await nextTick() + // expect(container.innerHTML).toBe( + // ``, + // ) - // isShown.value = true - // await nextTick() - // expect(container.innerHTML).toBe( - // `` + - // `
` + - // `` + - // `
child
` + - // `
` + - // `
` + - // `
`, - // ) - // }) - // }) + // isShown.value = true + // await nextTick() + // expect(container.innerHTML).toBe( + // `` + + // `
` + + // `` + + // `
child
` + + // `
` + + // `
` + + // `
`, + // ) + // }) + }) // describe('helpers', () => { // test('useHost', () => { From 836db6bce6bfdf0f704f26e26b703aac253d12af Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 17:26:56 +0800 Subject: [PATCH 15/24] wip: save --- packages/runtime-dom/src/apiCustomElement.ts | 63 ++++++++++++++++++- .../__tests__/customElement.spec.ts | 6 +- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 341ed48e3df..1a67726de03 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -632,11 +632,15 @@ export abstract class VueElementBase< 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 @@ -650,11 +654,66 @@ export abstract class VueElementBase< } } parent.insertBefore(n, o) + replacementNodes.push(n) + } + } else { + while (o.firstChild) { + const child = o.firstChild + parent.insertBefore(child, o) + replacementNodes.push(child) + } + } + + slotReplacements.set(o, replacementNodes) + } + + // For Vapor: update fragment nodes before removing slots from DOM + if (slotReplacements.size > 0 && this._instance && this._instance.vapor) { + // @ts-expect-error TODO refactor + this._replaceNodesInFragments(this._instance.block, slotReplacements) + } + + // Now safe to remove slots from DOM + slotReplacements.forEach((_, o) => o.parentNode!.removeChild(o)) + } + + /** + * Replace slot nodes with their content in fragment nodes arrays + * @internal + */ + private _replaceNodesInFragments( + block: any, + replacements: Map, + ): void { + if (!block) return + + if (Array.isArray(block)) { + for (let i = 0; i < block.length; i++) { + this._replaceNodesInFragments(block[i], replacements) + } + } else if (block.nodes !== undefined) { + // This is a fragment with nodes property + if (Array.isArray(block.nodes)) { + // Replace slot nodes with their content + const newNodes: any[] = [] + for (const node of block.nodes) { + if (node instanceof Node && replacements.has(node)) { + // Replace with the content nodes + newNodes.push(...replacements.get(node)!) + } else { + newNodes.push(node) + // Recursively process nested fragments + this._replaceNodesInFragments(node, replacements) + } } + block.nodes = newNodes + } else if (block.nodes instanceof Node && replacements.has(block.nodes)) { + // Replace single node with its content + const replacement = replacements.get(block.nodes)! + block.nodes = replacement.length === 1 ? replacement[0] : replacement } else { - while (o.firstChild) parent.insertBefore(o.firstChild, o) + this._replaceNodesInFragments(block.nodes, replacements) } - parent.removeChild(o) } } diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 894d4976391..3f0f35fa36b 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -1344,7 +1344,7 @@ describe('defineVaporCustomElement', () => { ) customElements.define('my-el-shadowroot-false-slots', ES) - test.todo('should render slots', async () => { + test('should render slots', async () => { container.innerHTML = `` + `defaulttext` + @@ -1362,7 +1362,9 @@ describe('defineVaporCustomElement', () => { toggle.value = false await nextTick() expect(e.innerHTML).toBe( - `defaulttext` + `` + `
fallback
`, + `defaulttext` + + `` + + `
fallback
`, ) }) From cbf18fb848ca286a783c4a5c56605f4a05e3641c Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 21:24:20 +0800 Subject: [PATCH 16/24] refactor: handling slot update --- packages/runtime-dom/src/apiCustomElement.ts | 59 ++++--------------- .../src/apiDefineVaporCustomElement.ts | 51 +++++++++++++++- 2 files changed, 60 insertions(+), 50 deletions(-) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 1a67726de03..12cb859abce 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -255,6 +255,7 @@ export abstract class VueElementBase< protected abstract _mount(def: Def): void protected abstract _update(): void protected abstract _unmount(): void + protected abstract _updateSlotNodes(slot: Map): void constructor( /** @@ -663,58 +664,11 @@ export abstract class VueElementBase< replacementNodes.push(child) } } - + parent.removeChild(o) slotReplacements.set(o, replacementNodes) } - // For Vapor: update fragment nodes before removing slots from DOM - if (slotReplacements.size > 0 && this._instance && this._instance.vapor) { - // @ts-expect-error TODO refactor - this._replaceNodesInFragments(this._instance.block, slotReplacements) - } - - // Now safe to remove slots from DOM - slotReplacements.forEach((_, o) => o.parentNode!.removeChild(o)) - } - - /** - * Replace slot nodes with their content in fragment nodes arrays - * @internal - */ - private _replaceNodesInFragments( - block: any, - replacements: Map, - ): void { - if (!block) return - - if (Array.isArray(block)) { - for (let i = 0; i < block.length; i++) { - this._replaceNodesInFragments(block[i], replacements) - } - } else if (block.nodes !== undefined) { - // This is a fragment with nodes property - if (Array.isArray(block.nodes)) { - // Replace slot nodes with their content - const newNodes: any[] = [] - for (const node of block.nodes) { - if (node instanceof Node && replacements.has(node)) { - // Replace with the content nodes - newNodes.push(...replacements.get(node)!) - } else { - newNodes.push(node) - // Recursively process nested fragments - this._replaceNodesInFragments(node, replacements) - } - } - block.nodes = newNodes - } else if (block.nodes instanceof Node && replacements.has(block.nodes)) { - // Replace single node with its content - const replacement = replacements.get(block.nodes)! - block.nodes = replacement.length === 1 ? replacement[0] : replacement - } else { - this._replaceNodesInFragments(block.nodes, replacements) - } - } + this._updateSlotNodes(slotReplacements) } /** @@ -814,6 +768,13 @@ export class VueElement extends VueElementBase< 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) { diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index 6cf9b5f67aa..4f01a50ed48 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -1,5 +1,10 @@ import { extend, isPlainObject } from '@vue/shared' -import { createComponent, createVaporApp, defineVaporComponent } from '.' +import { + createComponent, + createVaporApp, + defineVaporComponent, + isFragment, +} from '.' import { type CreateAppFunction, type CustomElementOptions, @@ -11,6 +16,7 @@ import type { VaporComponent, VaporComponentInstance, } from './component' +import type { Block } from './block' export type VaporElementConstructor

= { new (initialProps?: Record): VaporElement & P @@ -124,6 +130,49 @@ export class VaporElement extends VueElementBase< 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 From ccbb1703fa0b10c414e965bb3e5968a03fe1d4f2 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 28 Oct 2025 22:05:51 +0800 Subject: [PATCH 17/24] test: enhance nested custom element and teleport rendering with shadowRoot: false --- .../__tests__/customElement.spec.ts | 370 ++++++++++-------- .../src/apiDefineVaporCustomElement.ts | 11 +- .../runtime-vapor/src/components/Teleport.ts | 12 + 3 files changed, 218 insertions(+), 175 deletions(-) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 3f0f35fa36b..f7ef9f8b827 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -5,11 +5,13 @@ import { type Ref, inject, nextTick, + onMounted, provide, ref, toDisplayString, } from '@vue/runtime-dom' import { + VaporTeleport, child, createComponent, createComponentWithFallback, @@ -1368,189 +1370,209 @@ describe('defineVaporCustomElement', () => { ) }) - // test('render nested customElement w/ shadowRoot false', async () => { - // const calls: string[] = [] + test('render nested customElement w/ shadowRoot false', async () => { + const calls: string[] = [] - // const Child = defineVaporCustomElement( - // { - // setup() { - // calls.push('child rendering') - // onMounted(() => { - // calls.push('child mounted') - // }) - // }, - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-child', Child) + 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') - // }) - // }, - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-parent', Parent) + 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 = { - // render() { - // return h('my-parent', null, { - // default: () => [ - // h('my-child', null, { - // default: () => [h('span', null, '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() - // }) + const App = { + setup() { + return createComponentWithFallback('my-parent', null, { + default: () => + createComponentWithFallback('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( - // { - // render() { - // return h( - // Teleport, - // { to: target }, - // { - // default: () => [renderSlot(this.$slots, 'default')], - // }, - // ) - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-teleport-child', Child) - // const Parent = defineVaporCustomElement( - // { - // render() { - // return renderSlot(this.$slots, 'default') - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-teleport-parent', Parent) + 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 = { - // render() { - // return h('my-el-teleport-parent', null, { - // default: () => [ - // h('my-el-teleport-child', null, { - // default: () => [h('span', null, 'default')], - // }), - // ], - // }) - // }, - // } - // const app = createVaporApp(App) - // app.mount(container) - // await nextTick() - // expect(target.innerHTML).toBe(`default`) - // app.unmount() - // }) + const App = { + setup() { + return createComponentWithFallback('my-el-teleport-parent', null, { + default: () => + createComponentWithFallback('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( - // { - // render() { - // return [ - // h(Teleport, { to: target1 }, [renderSlot(this.$slots, 'header')]), - // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), - // ] - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-two-teleport-child', Child) + 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 = { - // render() { - // return h('my-el-two-teleport-child', null, { - // default: () => [ - // h('div', { slot: 'header' }, 'header'), - // h('span', { slot: 'body' }, 'body'), - // ], - // }) - // }, - // } - // const app = createVaporApp(App) - // app.mount(container) - // await nextTick() - // expect(target1.outerHTML).toBe( - // `

header
`, - // ) - // expect(target2.outerHTML).toBe( - // `body`, - // ) - // app.unmount() - // }) + const App = { + setup() { + return createComponentWithFallback('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( - // { - // render() { - // return [ - // // with disabled: true - // h(Teleport, { to: target1, disabled: true }, [ - // renderSlot(this.$slots, 'header'), - // ]), - // h(Teleport, { to: target2 }, [renderSlot(this.$slots, 'body')]), - // ] - // }, - // }, - // { shadowRoot: false }, - // ) - // customElements.define('my-el-two-teleport-child-0', Child) + 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 = { - // render() { - // return h('my-el-two-teleport-child-0', null, { - // default: () => [ - // h('div', { slot: 'header' }, 'header'), - // h('span', { slot: 'body' }, 'body'), - // ], - // }) - // }, - // } - // const app = createVaporApp(App) - // app.mount(container) - // await nextTick() - // expect(target1.outerHTML).toBe(`
`) - // expect(target2.outerHTML).toBe( - // `body`, - // ) - // app.unmount() - // }) + const App = { + setup() { + return createComponentWithFallback( + '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( diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index 4f01a50ed48..a3158cc3abb 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -95,6 +95,12 @@ export class VaporElement extends VueElementBase< 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 { @@ -176,8 +182,11 @@ export class VaporElement extends VueElementBase< 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) { - this._instance!.m = this._instance!.u = [this._renderSlots.bind(this)] + // Still set updated hooks for subsequent updates + this._instance!.u = [this._renderSlots.bind(this)] } this._processInstance() } diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index ef3d4598c9b..72367867436 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, @@ -53,11 +55,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__ @@ -145,6 +149,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( From cf9384588a5f8254d1588482187c05fb5d1c8031 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 29 Oct 2025 09:13:04 +0800 Subject: [PATCH 18/24] wip: save --- .../__tests__/customElement.spec.ts | 185 ++++++++++-------- 1 file changed, 102 insertions(+), 83 deletions(-) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index f7ef9f8b827..d91d33c77b8 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -1574,91 +1574,110 @@ describe('defineVaporCustomElement', () => { app.unmount() }) - // test('toggle nested custom element with shadowRoot: false', async () => { - // customElements.define( - // 'my-el-child-shadow-false', - // defineVaporCustomElement( - // { - // render(ctx: any) { - // return h('div', null, [renderSlot(ctx.$slots, 'default')]) - // }, - // }, - // { shadowRoot: false }, - // ), - // ) - // const ChildWrapper = { - // render() { - // return h('my-el-child-shadow-false', null, 'child') - // }, - // } - - // customElements.define( - // 'my-el-parent-shadow-false', - // defineVaporCustomElement( - // { - // props: { - // isShown: { type: Boolean, required: true }, - // }, - // render(ctx: any, _: any, $props: any) { - // return $props.isShown - // ? h('div', { key: 0 }, [renderSlot(ctx.$slots, 'default')]) - // : null - // }, - // }, - // { shadowRoot: false }, - // ), - // ) - // const ParentWrapper = { - // props: { - // isShown: { type: Boolean, required: true }, - // }, - // render(ctx: any, _: any, $props: any) { - // return h('my-el-parent-shadow-false', { isShown: $props.isShown }, [ - // renderSlot(ctx.$slots, 'default'), - // ]) - // }, - // } - - // const isShown = ref(true) - // const App = { - // render() { - // return h(ParentWrapper, { isShown: isShown.value } as any, { - // default: () => [h(ChildWrapper)], - // }) - // }, - // } - // const container = document.createElement('div') - // document.body.appendChild(container) - // const app = createVaporApp(App) - // app.mount(container) - // expect(container.innerHTML).toBe( - // `` + - // `
` + - // `` + - // `
child
` + - // `
` + - // `
` + - // `
`, - // ) + 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 createComponentWithFallback('my-el-child-shadow-false', null, { + default: () => template('child')(), + }) + }, + } - // isShown.value = false - // await nextTick() - // expect(container.innerHTML).toBe( - // ``, - // ) + 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 createComponentWithFallback( + 'my-el-parent-shadow-false', + { isShown: () => props.isShown }, + { + default: () => createSlot('default'), + }, + ) + }, + } - // isShown.value = true - // await nextTick() - // expect(container.innerHTML).toBe( - // `` + - // `
` + - // `` + - // `
child
` + - // `
` + - // `
` + - // `
`, - // ) - // }) + 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', () => { From 9b5e134582e2146dc879480d4869c0152cafc117 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 29 Oct 2025 09:58:24 +0800 Subject: [PATCH 19/24] refactor: replace getCurrentInstance with getCurrentGenericInstance in useHost function --- packages/runtime-core/src/index.ts | 5 + packages/runtime-dom/src/apiCustomElement.ts | 4 +- .../__tests__/customElement.spec.ts | 809 +++++++++--------- 3 files changed, 418 insertions(+), 400 deletions(-) diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index a05f8985e1a..be0633548ca 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 { getCurrentGenericInstance } 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 12cb859abce..40d21ec0be3 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -27,7 +27,7 @@ import { type VNodeProps, createVNode, defineComponent, - getCurrentInstance, + getCurrentGenericInstance, nextTick, unref, warn, @@ -793,7 +793,7 @@ export class VueElement extends VueElementBase< } export function useHost(caller?: string): VueElementBase | null { - const instance = getCurrentInstance() + const instance = getCurrentGenericInstance() const el = instance && (instance.ce as VueElementBase) if (el) { return el diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index d91d33c77b8..ab651711a47 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -1,4 +1,4 @@ -// import type { MockedFunction } from 'vitest' +import type { MockedFunction } from 'vitest' import type { VaporElement } from '../src/apiDefineVaporCustomElement' import { type HMRRuntime, @@ -9,6 +9,8 @@ import { provide, ref, toDisplayString, + useHost, + useShadowRoot, } from '@vue/runtime-dom' import { VaporTeleport, @@ -606,24 +608,22 @@ describe('defineVaporCustomElement', () => { // https://github.com/vuejs/core/issues/12964 // Disabled because of missing support for `delegatesFocus` in jsdom // https://github.com/jsdom/jsdom/issues/3418 - // test.skip('shadowRoot should be initialized with delegatesFocus', () => { - // const E = defineVaporCustomElement( - // { - // // render() { - // // return [h('input', { tabindex: 1 })] - // // }, - // setup() { - // return template('', true)() - // }, - // }, - // { shadowRootOptions: { delegatesFocus: true } }, - // ) - // customElements.define('my-el-with-delegate-focus', E) - - // const e = new E() - // container.appendChild(e) - // expect(e.shadowRoot!.delegatesFocus).toBe(true) - // }) + // 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', () => { @@ -1680,292 +1680,301 @@ describe('defineVaporCustomElement', () => { }) }) - // describe('helpers', () => { - // test('useHost', () => { - // const Foo = defineVaporCustomElement({ - // setup() { - // const host = useHost()! - // host.setAttribute('id', 'host') - // return () => h('div', '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 () => h('div', '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('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') + }) - // describe('expose', () => { - // test('expose w/ options api', async () => { - // const E = defineVaporCustomElement({ - // data() { - // return { - // value: 0, - // } - // }, - // methods: { - // foo() { - // ;(this as any).value++ - // }, - // }, - // expose: ['foo'], - // render(_ctx: any) { - // return h('div', null, _ctx.value) - // }, - // }) - // customElements.define('my-el-expose-options-api', E) + 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; }`) + }) + }) - // 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(_, { expose }) { - // const value = ref('hello') - - // const setValue = (fn = vi.fn((_value: string) => { - // value.value = _value - // })) - - // expose({ - // setValue, - // value, - // }) - - // return () => h('div', null, [value.value]) - // }, - // }) - // customElements.define('my-el-expose', E) + 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 & { - // 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, { expose }) { - // expose({ - // value: 'hello', - // }) + 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
`) + }) - // return () => h('div', null, [props.value]) - // }, - // }) - // customElements.define('my-el-expose-two', E) + test('expose attributes and callback', async () => { + type SetValue = (value: string) => void + let fn: MockedFunction - // container.innerHTML = `` + const E = defineVaporCustomElement({ + setup(_: any, { expose }: any) { + const value = ref('hello') - // expect( - // `[Vue warn]: Exposed property "value" already exists on custom element.`, - // ).toHaveBeenWarned() - // }) - // }) + const setValue = (fn = vi.fn((_value: string) => { + value.value = _value + })) - // test('async & nested custom elements', async () => { - // let fooVal: string | undefined = '' - // const E = defineVaporCustomElement( - // defineVaporAsyncComponent(() => { - // return Promise.resolve({ - // setup(props) { - // provide('foo', 'foo') - // }, - // render(this: any) { - // return h('div', null, [renderSlot(this.$slots, 'default')]) - // }, - // }) - // }), - // ) + expose({ + setValue, + value, + }) - // const EChild = defineVaporCustomElement({ - // setup(props) { - // fooVal = inject('foo') - // }, - // render(this: any) { - // return h('div', null, 'child') - // }, - // }) - // 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') - // }) + 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) - // 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(props) { - // provide('foo', 'foo') - // }, - // render(this: any) { - // return h('div', null, [renderSlot(this.$slots, 'default')]) - // }, - // }) - // }), - // ) + 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
`) + }) - // const EChild = defineVaporCustomElement({ - // setup(props) { - // provide('bar', 'bar') - // }, - // render(this: any) { - // return h('div', null, [renderSlot(this.$slots, 'default')]) - // }, - // }) - - // const EChild2 = defineVaporCustomElement({ - // setup(props) { - // fooVal = inject('foo') - // barVal = inject('bar') - // }, - // render(this: any) { - // return h('div', null, 'child') - // }, - // }) - // 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') - // }) + test('warning when exposing an existing property', () => { + const E = defineVaporCustomElement({ + props: { + value: String, + }, + setup(props: any, { expose }: any) { + expose({ + value: 'hello', + }) - // describe('configureApp', () => { - // test('should work', () => { - // const E = defineVaporCustomElement( - // () => { - // const msg = inject('msg') - // return () => h('div', msg!) - // }, - // { - // configureApp(app) { - // app.provide('msg', 'app-injected') - // }, - // }, - // ) - // customElements.define('my-element-with-app', E) - - // container.innerHTML = `` - // const e = container.childNodes[0] as VaporElement - - // expect(e.shadowRoot?.innerHTML).toBe('
app-injected
') - // }) - - // // #12448 - // test('work with async component', async () => { - // const AsyncComp = defineVaporAsyncComponent(() => { - // return Promise.resolve({ - // render() { - // const msg: string | undefined = inject('msg') - // return h('div', {}, msg) - // }, - // } as any) - // }) - // const E = defineVaporCustomElement(AsyncComp, { - // configureApp(app) { - // app.provide('msg', 'app-injected') - // }, - // }) - // 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') - // return { msg } - // }, - // render(this: any) { - // return h('div', [h('span', this.msg), h('span', this.$foo)]) - // }, - // }) - // const E = defineVaporCustomElement(def, { - // configureApp(app) { - // app.provide('msg', 'app-injected') - // app.config.globalProperties.$foo = 'foo' - // }, - // }) - // customElements.define('my-element-with-app-hmr', E) - - // container.innerHTML = `` - // const el = container.childNodes[0] as VaporElement - // expect(el.shadowRoot?.innerHTML).toBe( - // `
app-injectedfoo
`, - // ) - - // // hmr - // __VUE_HMR_RUNTIME__.reload(__hmrId, def as any) - - // await nextTick() - // expect(el.shadowRoot?.innerHTML).toBe( - // `
app-injectedfoo
`, - // ) - // }) - // }) + 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') + }) - // // #9885 + 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', @@ -1981,121 +1990,125 @@ describe('defineVaporCustomElement', () => { // const container = document.createElement('div') // document.body.appendChild(container) // createVaporApp({ - // render() { - // return h('div', [ - // h('my-input-dupe', { - // onVnodeMounted(vnode) { - // vnode.el!.value = 'fesfes' - // }, - // }), - // ]) - // }, + // // 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', // ) // }) - // // #11081 - // test('Props can be casted when mounting custom elements in component rendering functions', async () => { - // const E = defineVaporCustomElement( - // defineVaporAsyncComponent(() => - // Promise.resolve({ - // props: ['fooValue'], - // setup(props) { - // expect(props.fooValue).toBe('fooValue') - // return () => h('div', props.fooValue) - // }, - // }), - // ), - // ) - // customElements.define('my-el-async-4', E) - // const R = defineVaporComponent({ - // setup() { - // const fooValue = ref('fooValue') - // return () => { - // return h('div', null, [ - // h('my-el-async-4', { - // fooValue: fooValue.value, - // }), - // ]) - // } - // }, - // }) - - // 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('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) + createComponentWithFallback('my-el-async-4', { + fooValue: () => fooValue.value, + }) + return n0 + }, + }) - // // #11276 - // test('delete prop on attr removal', async () => { - // const E = defineVaporCustomElement({ - // props: { - // boo: { - // type: Boolean, - // }, - // }, - // render() { - // return this.boo + ',' + typeof this.boo - // }, - // }) - // 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`) - // }) + 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('hyphenated attr removal', async () => { - // const E = defineVaporCustomElement({ - // props: { - // fooBar: { - // type: Boolean, - // }, - // }, - // render() { - // return this.fooBar - // }, - // }) - // customElements.define('el-hyphenated-attr-removal', E) - // const toggle = ref(true) - // const Comp = { - // render() { - // return h('el-hyphenated-attr-removal', { - // 'foo-bar': toggle.value ? '' : null, - // }) - // }, - // } - // render(h(Comp), container) - // const el = container.children[0] - // expect(el.hasAttribute('foo-bar')).toBe(true) - // expect((el as any).outerHTML).toBe( - // ``, - // ) + 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`) + }) - // toggle.value = false - // await nextTick() - // expect(el.hasAttribute('foo-bar')).toBe(false) - // expect((el as any).outerHTML).toBe( - // ``, - // ) - // }) + 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( + ``, + ) - // test('no unexpected mutation of the 1st argument', () => { - // const Foo = { - // name: 'Foo', - // } + toggle.value = false + await nextTick() + expect(el.hasAttribute('foo-bar')).toBe(false) + expect((el as any).outerHTML).toBe( + ``, + ) + }) - // defineVaporCustomElement(Foo, { shadowRoot: false }) + test('no unexpected mutation of the 1st argument', () => { + const Foo = { + __vapor: true, + name: 'Foo', + } - // expect(Foo).toEqual({ - // name: 'Foo', - // }) - // }) + defineVaporCustomElement(Foo, { shadowRoot: false } as any) + + expect(Foo).toEqual({ + __vapor: true, + name: 'Foo', + }) + }) }) From 6bc0420b6805dd3acf52f688dd288b108c506d83 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 29 Oct 2025 15:54:45 +0800 Subject: [PATCH 20/24] wip: add SSR support --- .../src/apiDefineVaporCustomElement.ts | 11 +- .../e2e/ssr-vapor-custom-element.spec.ts | 184 ++++++++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index a3158cc3abb..7624b66cee1 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -2,6 +2,7 @@ import { extend, isPlainObject } from '@vue/shared' import { createComponent, createVaporApp, + createVaporSSRApp, defineVaporComponent, isFragment, } from '.' @@ -17,6 +18,7 @@ import type { VaporComponentInstance, } from './component' import type { Block } from './block' +import { withHydration } from './dom/hydration' export type VaporElementConstructor

= { new (initialProps?: Record): VaporElement & P @@ -50,7 +52,6 @@ export const defineVaporSSRCustomElement = (( options: any, extraOptions?: Omit, ) => { - // @ts-expect-error return defineVaporCustomElement(options, extraOptions, createVaporSSRApp) }) as typeof defineVaporCustomElement @@ -93,7 +94,13 @@ export class VaporElement extends VueElementBase< this._def.configureApp(this._app) } - this._createComponent() + // For SSR custom elements, we need to create component in hydration context + if (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 diff --git a/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts b/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts new file mode 100644 index 00000000000..065cb0fd68a --- /dev/null +++ b/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts @@ -0,0 +1,184 @@ +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') +}) + +// test('work with Teleport (shadowRoot: false)', async () => { +// await setContent( +// `
`, +// ) + +// await page().evaluate(() => { +// const { +// defineVaporSSRCustomElement, +// createComponent, +// createSlot, +// VaporTeleport, +// createComponentWithFallback, +// template, +// } = (window as any).VueVapor +// const Y = defineVaporSSRCustomElement( +// { +// setup() { +// const n1 = createComponent( +// VaporTeleport, +// { to: () => '#test' }, +// { +// default: () => { +// const n0 = createSlot('default', null) +// return n0 +// }, +// }, +// true, +// ) +// return n1 +// }, +// }, +// { shadowRoot: false }, +// ) +// customElements.define('my-y', Y) +// const P = defineVaporSSRCustomElement( +// { +// setup() { +// return createComponentWithFallback('my-y', null, { +// default: () => template('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') +// }) From 579ace9747b63cac18dbe7e70617de694e4f69ff Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 29 Oct 2025 16:34:27 +0800 Subject: [PATCH 21/24] test: remove tests defineSSRCustomElement can only be used with custom elements that have pre-rendered declarative shadow root --- .../__tests__/e2e/ssr-custom-element.spec.ts | 43 -------------- .../e2e/ssr-vapor-custom-element.spec.ts | 56 ------------------- 2 files changed, 99 deletions(-) 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[] = [] diff --git a/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts b/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts index 065cb0fd68a..c50bff6709c 100644 --- a/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts +++ b/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts @@ -126,59 +126,3 @@ test('ssr vapor custom element hydration', async () => { await assertInteraction('my-element') await assertInteraction('my-element-async') }) - -// test('work with Teleport (shadowRoot: false)', async () => { -// await setContent( -// `
`, -// ) - -// await page().evaluate(() => { -// const { -// defineVaporSSRCustomElement, -// createComponent, -// createSlot, -// VaporTeleport, -// createComponentWithFallback, -// template, -// } = (window as any).VueVapor -// const Y = defineVaporSSRCustomElement( -// { -// setup() { -// const n1 = createComponent( -// VaporTeleport, -// { to: () => '#test' }, -// { -// default: () => { -// const n0 = createSlot('default', null) -// return n0 -// }, -// }, -// true, -// ) -// return n1 -// }, -// }, -// { shadowRoot: false }, -// ) -// customElements.define('my-y', Y) -// const P = defineVaporSSRCustomElement( -// { -// setup() { -// return createComponentWithFallback('my-y', null, { -// default: () => template('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') -// }) From fb06fcf8aba60b21b067ab5f8904d929683ab7a3 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 29 Oct 2025 16:56:28 +0800 Subject: [PATCH 22/24] chore: tweaks --- packages/runtime-dom/src/apiCustomElement.ts | 29 +++++++++++++------ .../src/apiDefineVaporCustomElement.ts | 10 +++---- packages/runtime-vapor/src/componentProps.ts | 2 +- ...ec.ts => ssr-custom-element-vapor.spec.ts} | 0 4 files changed, 26 insertions(+), 15 deletions(-) rename packages/vue/__tests__/e2e/{ssr-vapor-custom-element.spec.ts => ssr-custom-element-vapor.spec.ts} (100%) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 40d21ec0be3..cf95eb4a805 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -220,7 +220,7 @@ export abstract class VueElementBase< /** * @internal */ - _root!: Element | ShadowRoot + _root: Element | ShadowRoot /** * @internal */ @@ -230,9 +230,6 @@ export abstract class VueElementBase< */ _teleportTargets?: Set - protected _def: Def - protected _props: Record - protected _createApp: CreateAppFunction protected _connected = false protected _resolved = false protected _numberProps: Record | null = null @@ -240,6 +237,10 @@ export abstract class VueElementBase< protected _pendingResolve: Promise | undefined protected _parent: VueElementBase | undefined + protected _def: Def + protected _props: Record + protected _createApp: CreateAppFunction + /** * dev only */ @@ -251,7 +252,12 @@ export abstract class VueElementBase< protected _ob?: MutationObserver | null = null protected _slots?: Record - protected abstract _hasPreRendered(): boolean | undefined + /** + * 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 @@ -272,7 +278,9 @@ export abstract class VueElementBase< this._createApp = createAppFn this._nonce = def.nonce - if (this._hasPreRendered()) { + if (this._needsHydration()) { + this._root = this.shadowRoot! + } else { if (def.shadowRoot !== false) { this.attachShadow( extend({}, def.shadowRootOptions, { @@ -439,6 +447,7 @@ export abstract class VueElementBase< private _mountComponent(def: Def): void { this._mount(def) + // apply expose after mount this._processExposed() } @@ -447,7 +456,9 @@ export abstract class VueElementBase< if (!exposed) return for (const key in exposed) { if (!hasOwn(this, key)) { + // exposed properties are readonly Object.defineProperty(this, key, { + // unwrap ref to be consistent with public instance behavior get: () => unref(exposed[key]), }) } else if (__DEV__) { @@ -723,9 +734,9 @@ export class VueElement extends VueElementBase< super(def, props, createAppFn) } - protected _hasPreRendered(): boolean | undefined { + protected _needsHydration(): boolean { if (this.shadowRoot && this._createApp !== createApp) { - this._root = this.shadowRoot + return true } else { if (__DEV__ && this.shadowRoot) { warn( @@ -733,8 +744,8 @@ export class VueElement extends VueElementBase< `defined as hydratable. Use \`defineSSRCustomElement\`.`, ) } - return true } + return false } protected _mount(def: InnerComponentDef): void { diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts index 7624b66cee1..f896b49eb5b 100644 --- a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts @@ -70,9 +70,9 @@ export class VaporElement extends VueElementBase< super(def, props, createAppFn) } - protected _hasPreRendered(): boolean | undefined { + protected _needsHydration(): boolean { if (this.shadowRoot && this._createApp !== createVaporApp) { - this._root = this.shadowRoot + return true } else { if (__DEV__ && this.shadowRoot) { warn( @@ -80,8 +80,8 @@ export class VaporElement extends VueElementBase< `defined as hydratable. Use \`defineVaporSSRCustomElement\`.`, ) } - return true } + return false } protected _mount(def: VaporInnerComponentDef): void { if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) { @@ -94,8 +94,8 @@ export class VaporElement extends VueElementBase< this._def.configureApp(this._app) } - // For SSR custom elements, we need to create component in hydration context - if (this._createApp === createVaporSSRApp) { + // create component in hydration context + if (this.shadowRoot && this._createApp === createVaporSSRApp) { withHydration(this._root, this._createComponent.bind(this)) } else { this._createComponent() diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 496b001d3a2..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, - instance.type.ce ? rawProps[rawKey] : rawProps[rawKey](), + resolveSource(rawProps[rawKey]), instance, resolveDefault, ) diff --git a/packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts b/packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts similarity index 100% rename from packages/vue/__tests__/e2e/ssr-vapor-custom-element.spec.ts rename to packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts From d5b2e383446cff2805abe08c0597a324c480704e Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 29 Oct 2025 17:41:36 +0800 Subject: [PATCH 23/24] refactor: add createPlainElement helper --- .../transformElement.spec.ts.snap | 9 ++++ .../transforms/transformElement.spec.ts | 11 +++++ .../src/generators/component.ts | 2 +- .../src/transforms/transformElement.ts | 7 +-- .../__tests__/customElement.spec.ts | 44 +++++++++---------- packages/runtime-vapor/src/component.ts | 10 +++++ packages/runtime-vapor/src/index.ts | 1 + 7 files changed, 56 insertions(+), 28 deletions(-) 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 fd770f16d35..8bd0a02a1c7 100644 --- a/packages/compiler-vapor/src/generators/component.ts +++ b/packages/compiler-vapor/src/generators/component.ts @@ -74,7 +74,7 @@ export function genCreateComponent( operation.dynamic && !operation.dynamic.isStatic ? helper('createDynamicComponent') : operation.isCustomElement - ? helper('createComponentWithFallback') + ? helper('createPlainElement') : operation.asset ? helper('createComponentWithFallback') : helper('createComponent'), diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index 136f29d8a4e..c9b5a9d7a3f 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -82,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( diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index ab651711a47..47116e324b0 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -16,8 +16,8 @@ import { VaporTeleport, child, createComponent, - createComponentWithFallback, createIf, + createPlainElement, createSlot, createVaporApp, defineVaporAsyncComponent, @@ -50,7 +50,7 @@ describe('defineVaporCustomElement', () => { document.body.appendChild(root) createVaporApp({ setup() { - return createComponentWithFallback(tag, props, null, true) + return createPlainElement(tag, props, null, true) }, }).mount(root) @@ -125,7 +125,7 @@ describe('defineVaporCustomElement', () => { setup() { const n1 = template('
', true)() as any setInsertionState(n1, 0, true) - createComponentWithFallback('my-el-input', { + createPlainElement('my-el-input', { value: () => num.value, onInput: () => ($event: CustomEvent) => { num.value = $event.detail[0] @@ -832,7 +832,7 @@ describe('defineVaporCustomElement', () => { const Provider = defineVaporCustomElement({ setup() { provide('foo', foo) - return createComponentWithFallback('my-consumer') + return createPlainElement('my-consumer') }, }) customElements.define('my-provider', Provider) @@ -873,13 +873,13 @@ describe('defineVaporCustomElement', () => { const ProviderA = defineVaporCustomElement({ setup() { provide('fooA', fooA) - return createComponentWithFallback('provider-b') + return createPlainElement('provider-b') }, }) const ProviderB = defineVaporCustomElement({ setup() { provide('fooB', fooB) - return createComponentWithFallback('my-multi-consumer') + return createPlainElement('my-multi-consumer') }, }) @@ -1403,9 +1403,9 @@ describe('defineVaporCustomElement', () => { const App = { setup() { - return createComponentWithFallback('my-parent', null, { + return createPlainElement('my-parent', null, { default: () => - createComponentWithFallback('my-child', null, { + createPlainElement('my-child', null, { default: () => template('default')(), }), }) @@ -1456,9 +1456,9 @@ describe('defineVaporCustomElement', () => { const App = { setup() { - return createComponentWithFallback('my-el-teleport-parent', null, { + return createPlainElement('my-el-teleport-parent', null, { default: () => - createComponentWithFallback('my-el-teleport-child', null, { + createPlainElement('my-el-teleport-child', null, { default: () => template('default')(), }), }) @@ -1501,7 +1501,7 @@ describe('defineVaporCustomElement', () => { const App = { setup() { - return createComponentWithFallback('my-el-two-teleport-child', null, { + return createPlainElement('my-el-two-teleport-child', null, { default: () => [ template('
header
')(), template('body')(), @@ -1552,16 +1552,12 @@ describe('defineVaporCustomElement', () => { const App = { setup() { - return createComponentWithFallback( - 'my-el-two-teleport-child-0', - null, - { - default: () => [ - template('
header
')(), - template('body')(), - ], - }, - ) + return createPlainElement('my-el-two-teleport-child-0', null, { + default: () => [ + template('
header
')(), + template('body')(), + ], + }) }, } const app = createVaporApp(App) @@ -1591,7 +1587,7 @@ describe('defineVaporCustomElement', () => { ) const ChildWrapper = { setup() { - return createComponentWithFallback('my-el-child-shadow-false', null, { + return createPlainElement('my-el-child-shadow-false', null, { default: () => template('child')(), }) }, @@ -1624,7 +1620,7 @@ describe('defineVaporCustomElement', () => { isShown: { type: Boolean, required: true }, }, setup(props: any) { - return createComponentWithFallback( + return createPlainElement( 'my-el-parent-shadow-false', { isShown: () => props.isShown }, { @@ -2029,7 +2025,7 @@ describe('defineVaporCustomElement', () => { const fooValue = ref('fooValue') const n0 = template('
')() as any setInsertionState(n0, null) - createComponentWithFallback('my-el-async-4', { + createPlainElement('my-el-async-4', { fooValue: () => fooValue.value, }) return n0 diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 8073237b427..fb1db6c5a8b 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -642,6 +642,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 diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index d8fbbf0e97c..98bfda025d0 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -17,6 +17,7 @@ export { setInsertionState } from './insertionState' export { createComponent, createComponentWithFallback, + createPlainElement, isVaporComponent, } from './component' export { renderEffect } from './renderEffect' From 8a298fae5466f8e4873a757a502d3dd737e03971 Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 30 Oct 2025 21:34:45 +0800 Subject: [PATCH 24/24] feat: add useInstanceOption for safer instance access --- .../src/componentCurrentInstance.ts | 34 +++++++++++++++++++ packages/runtime-core/src/index.ts | 2 +- packages/runtime-dom/src/apiCustomElement.ts | 8 ++--- 3 files changed, 39 insertions(+), 5 deletions(-) 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/index.ts b/packages/runtime-core/src/index.ts index be0633548ca..67ff4f0e913 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -108,7 +108,7 @@ export { getCurrentInstance } from './component' /** * @internal */ -export { getCurrentGenericInstance } from './component' +export { useInstanceOption } from './component' // For raw render function users export { h } from './h' diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index cf95eb4a805..d3f8acfea27 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -27,9 +27,9 @@ import { type VNodeProps, createVNode, defineComponent, - getCurrentGenericInstance, nextTick, unref, + useInstanceOption, warn, } from '@vue/runtime-core' import { @@ -804,12 +804,12 @@ export class VueElement extends VueElementBase< } export function useHost(caller?: string): VueElementBase | null { - const instance = getCurrentGenericInstance() - const el = instance && (instance.ce as VueElementBase) + 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.`, )