= (app, container) => {
let instance: VaporComponentInstance
withHydration(container, () => {
- instance = createComponent(
- app._component,
- app._props as RawProps,
- null,
- false,
- false,
- app._context,
- )
+ instance =
+ (app._ceComponent as VaporComponentInstance) ||
+ createComponent(
+ app._component,
+ app._props as RawProps,
+ null,
+ false,
+ false,
+ app._context,
+ )
mountComponent(instance, container)
flushOnAppMount()
})
diff --git a/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts
new file mode 100644
index 00000000000..f896b49eb5b
--- /dev/null
+++ b/packages/runtime-vapor/src/apiDefineVaporCustomElement.ts
@@ -0,0 +1,210 @@
+import { extend, isPlainObject } from '@vue/shared'
+import {
+ createComponent,
+ createVaporApp,
+ createVaporSSRApp,
+ defineVaporComponent,
+ isFragment,
+} from '.'
+import {
+ type CreateAppFunction,
+ type CustomElementOptions,
+ VueElementBase,
+ warn,
+} from '@vue/runtime-dom'
+import type {
+ ObjectVaporComponent,
+ VaporComponent,
+ VaporComponentInstance,
+} from './component'
+import type { Block } from './block'
+import { withHydration } from './dom/hydration'
+
+export type VaporElementConstructor = {
+ new (initialProps?: Record): VaporElement & P
+}
+
+// TODO type inference
+
+/*@__NO_SIDE_EFFECTS__*/
+export function defineVaporCustomElement(
+ options: any,
+ extraOptions?: Omit,
+ /**
+ * @internal
+ */
+ _createApp?: CreateAppFunction,
+): VaporElementConstructor {
+ let Comp = defineVaporComponent(options, extraOptions)
+ if (isPlainObject(Comp)) Comp = extend({}, Comp, extraOptions)
+ class VaporCustomElement extends VaporElement {
+ static def = Comp
+ constructor(initialProps?: Record) {
+ super(Comp, initialProps, _createApp)
+ }
+ }
+
+ return VaporCustomElement
+}
+
+/*@__NO_SIDE_EFFECTS__*/
+export const defineVaporSSRCustomElement = ((
+ options: any,
+ extraOptions?: Omit,
+) => {
+ return defineVaporCustomElement(options, extraOptions, createVaporSSRApp)
+}) as typeof defineVaporCustomElement
+
+type VaporInnerComponentDef = VaporComponent & CustomElementOptions
+
+export class VaporElement extends VueElementBase<
+ ParentNode,
+ VaporComponent,
+ VaporInnerComponentDef
+> {
+ constructor(
+ def: VaporInnerComponentDef,
+ props: Record | undefined = {},
+ createAppFn: CreateAppFunction = createVaporApp,
+ ) {
+ super(def, props, createAppFn)
+ }
+
+ protected _needsHydration(): boolean {
+ if (this.shadowRoot && this._createApp !== createVaporApp) {
+ return true
+ } else {
+ if (__DEV__ && this.shadowRoot) {
+ warn(
+ `Custom element has pre-rendered declarative shadow root but is not ` +
+ `defined as hydratable. Use \`defineVaporSSRCustomElement\`.`,
+ )
+ }
+ }
+ return false
+ }
+ protected _mount(def: VaporInnerComponentDef): void {
+ if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) {
+ def.name = 'VaporElement'
+ }
+
+ this._app = this._createApp(this._def)
+ this._inheritParentContext()
+ if (this._def.configureApp) {
+ this._def.configureApp(this._app)
+ }
+
+ // create component in hydration context
+ if (this.shadowRoot && this._createApp === createVaporSSRApp) {
+ withHydration(this._root, this._createComponent.bind(this))
+ } else {
+ this._createComponent()
+ }
+
+ this._app!.mount(this._root)
+
+ // Render slots immediately after mount for shadowRoot: false
+ // This ensures correct lifecycle order for nested custom elements
+ if (!this.shadowRoot) {
+ this._renderSlots()
+ }
+ }
+
+ protected _update(): void {
+ if (!this._app) return
+ // update component by re-running all its render effects
+ const renderEffects = (this._instance! as VaporComponentInstance)
+ .renderEffects
+ if (renderEffects) renderEffects.forEach(e => e.run())
+ }
+
+ protected _unmount(): void {
+ if (__TEST__) {
+ try {
+ this._app!.unmount()
+ } catch (error) {
+ // In test environment, ignore errors caused by accessing Node
+ // after the test environment has been torn down
+ if (
+ error instanceof ReferenceError &&
+ error.message.includes('Node is not defined')
+ ) {
+ // Ignore this error in tests
+ } else {
+ throw error
+ }
+ }
+ } else {
+ this._app!.unmount()
+ }
+ if (this._instance && this._instance.ce) {
+ this._instance.ce = undefined
+ }
+ this._app = this._instance = null
+ }
+
+ /**
+ * Only called when shadowRoot is false
+ */
+ protected _updateSlotNodes(replacements: Map): void {
+ this._updateFragmentNodes(
+ (this._instance! as VaporComponentInstance).block,
+ replacements,
+ )
+ }
+
+ /**
+ * Replace slot nodes with their replace content
+ * @internal
+ */
+ private _updateFragmentNodes(
+ block: Block,
+ replacements: Map,
+ ): void {
+ if (Array.isArray(block)) {
+ block.forEach(item => this._updateFragmentNodes(item, replacements))
+ return
+ }
+
+ if (!isFragment(block)) return
+ const { nodes } = block
+ if (Array.isArray(nodes)) {
+ const newNodes: Block[] = []
+ for (const node of nodes) {
+ if (node instanceof HTMLSlotElement) {
+ newNodes.push(...replacements.get(node)!)
+ } else {
+ this._updateFragmentNodes(node, replacements)
+ newNodes.push(node)
+ }
+ }
+ block.nodes = newNodes
+ } else if (nodes instanceof HTMLSlotElement) {
+ block.nodes = replacements.get(nodes)!
+ } else {
+ this._updateFragmentNodes(nodes, replacements)
+ }
+ }
+
+ private _createComponent() {
+ this._def.ce = instance => {
+ this._app!._ceComponent = this._instance = instance
+ // For shadowRoot: false, _renderSlots is called synchronously after mount
+ // in _mount() to ensure correct lifecycle order
+ if (!this.shadowRoot) {
+ // Still set updated hooks for subsequent updates
+ this._instance!.u = [this._renderSlots.bind(this)]
+ }
+ this._processInstance()
+ }
+
+ createComponent(
+ this._def,
+ this._props,
+ undefined,
+ undefined,
+ undefined,
+ this._app!._context,
+ )
+ }
+}
diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts
index e850f08932d..3a4ad1d7c44 100644
--- a/packages/runtime-vapor/src/component.ts
+++ b/packages/runtime-vapor/src/component.ts
@@ -97,6 +97,7 @@ import {
resetInsertionState,
} from './insertionState'
import { DynamicFragment } from './fragment'
+import type { VaporElement } from './apiDefineVaporCustomElement'
export { currentInstance } from '@vue/runtime-dom'
@@ -130,6 +131,10 @@ export interface ObjectVaporComponent
name?: string
vapor?: boolean
+ /**
+ * @internal custom element interception hook
+ */
+ ce?: (instance: VaporComponentInstance) => void
}
interface SharedInternalOptions {
@@ -490,8 +495,15 @@ export class VaporComponentInstance implements GenericComponentInstance {
// for suspense
suspense: SuspenseBoundary | null
+ // for HMR and vapor custom element
+ // all render effects associated with this instance
+ renderEffects?: RenderEffect[]
+
hasFallthrough: boolean
+ // for keep-alive
+ shapeFlag?: number
+
// lifecycle hooks
isMounted: boolean
isUnmounted: boolean
@@ -518,12 +530,10 @@ export class VaporComponentInstance implements GenericComponentInstance {
devtoolsRawSetupState?: any
hmrRerender?: () => void
hmrReload?: (newComp: VaporComponent) => void
- renderEffects?: RenderEffect[]
parentTeleport?: TeleportFragment | null
propsOptions?: NormalizedPropsOptions
emitsOptions?: ObjectEmitsOptions | null
isSingleRoot?: boolean
- shapeFlag?: number
constructor(
comp: VaporComponent,
@@ -589,6 +599,11 @@ export class VaporComponentInstance implements GenericComponentInstance {
? new Proxy(rawSlots, dynamicSlotsProxyHandlers)
: rawSlots
: EMPTY_OBJ
+
+ // apply custom element special handling
+ if (comp.ce) {
+ comp.ce(this)
+ }
}
/**
@@ -630,6 +645,16 @@ export function createComponentWithFallback(
)
}
+ return createPlainElement(comp, rawProps, rawSlots, isSingleRoot, once)
+}
+
+export function createPlainElement(
+ comp: string,
+ rawProps?: LooseRawProps | null,
+ rawSlots?: LooseRawSlots | null,
+ isSingleRoot?: boolean,
+ once?: boolean,
+): HTMLElement {
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
const _isLastInsertion = isLastInsertion
@@ -695,6 +720,17 @@ export function mountComponent(
return
}
+ // custom element style injection
+ const { root, type } = instance as GenericComponentInstance
+ if (
+ root &&
+ root.ce &&
+ // @ts-expect-error _def is private
+ (root.ce as VaporElement)._def.shadowRoot !== false
+ ) {
+ root.ce!._injectChildStyle(type)
+ }
+
if (__DEV__) {
startMeasure(instance, `mount`)
}
diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts
index 6832bd9103c..c10008b3af2 100644
--- a/packages/runtime-vapor/src/componentProps.ts
+++ b/packages/runtime-vapor/src/componentProps.ts
@@ -97,7 +97,7 @@ export function getPropsProxyHandlers(
return resolvePropValue(
propsOptions!,
key,
- rawProps[rawKey](),
+ resolveSource(rawProps[rawKey]),
instance,
resolveDefault,
)
@@ -217,10 +217,11 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
}
}
if (hasOwn(rawProps, key)) {
+ const value = resolveSource(rawProps[key])
if (merged) {
- merged.push(rawProps[key]())
+ merged.push(value)
} else {
- return rawProps[key]()
+ return value
}
}
if (merged && merged.length) {
@@ -330,7 +331,7 @@ export function resolveDynamicProps(props: RawProps): Record {
const mergedRawProps: Record = {}
for (const key in props) {
if (key !== '$') {
- mergedRawProps[key] = props[key]()
+ mergedRawProps[key] = resolveSource(props[key])
}
}
if (props.$) {
diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts
index b2a3ff5fb97..01b0be5d4dd 100644
--- a/packages/runtime-vapor/src/componentSlots.ts
+++ b/packages/runtime-vapor/src/componentSlots.ts
@@ -1,7 +1,13 @@
import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared'
import { type Block, type BlockFn, insert, setScopeId } from './block'
import { rawPropsProxyHandlers } from './componentProps'
-import { currentInstance, isRef, setCurrentInstance } from '@vue/runtime-dom'
+import {
+ type GenericComponentInstance,
+ currentInstance,
+ isAsyncWrapper,
+ isRef,
+ setCurrentInstance,
+} from '@vue/runtime-dom'
import type { LooseRawProps, VaporComponentInstance } from './component'
import { renderEffect } from './renderEffect'
import {
@@ -16,6 +22,8 @@ import {
locateHydrationNode,
} from './dom/hydration'
import { DynamicFragment, type VaporFragment } from './fragment'
+import { createElement } from './dom/node'
+import { setDynamicProps } from './dom/prop'
/**
* Current slot scopeIds for vdom interop
@@ -184,7 +192,30 @@ export function createSlot(
}
const renderSlot = () => {
- const slot = getSlot(rawSlots, isFunction(name) ? name() : name)
+ const slotName = isFunction(name) ? name() : name
+
+ // in custom element mode, render as actual slot outlets
+ // because in shadowRoot: false mode the slot element gets
+ // replaced by injected content
+ if (
+ (instance as GenericComponentInstance).ce ||
+ (instance.parent &&
+ isAsyncWrapper(instance.parent) &&
+ instance.parent.ce)
+ ) {
+ const el = createElement('slot')
+ renderEffect(() => {
+ setDynamicProps(el, [
+ slotProps,
+ slotName !== 'default' ? { name: slotName } : {},
+ ])
+ })
+ if (fallback) insert(fallback(), el)
+ fragment.nodes = el
+ return
+ }
+
+ const slot = getSlot(rawSlots, slotName)
if (slot) {
fragment.fallback = fallback
// Create and cache bound version of the slot to make it stable
diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts
index 746798f71bf..3c061bc0b14 100644
--- a/packages/runtime-vapor/src/components/Teleport.ts
+++ b/packages/runtime-vapor/src/components/Teleport.ts
@@ -1,7 +1,9 @@
import {
+ type GenericComponentInstance,
MismatchTypes,
type TeleportProps,
type TeleportTargetElement,
+ currentInstance,
isMismatchAllowed,
isTeleportDeferred,
isTeleportDisabled,
@@ -54,11 +56,13 @@ export class TeleportFragment extends VaporFragment {
placeholder?: Node
mountContainer?: ParentNode | null
mountAnchor?: Node | null
+ parentComponent: GenericComponentInstance
constructor(props: LooseRawProps, slots: LooseRawSlots) {
super([])
this.rawProps = props
this.rawSlots = slots
+ this.parentComponent = currentInstance as GenericComponentInstance
this.anchor = isHydrating
? undefined
: __DEV__
@@ -149,6 +153,14 @@ export class TeleportFragment extends VaporFragment {
insert((this.targetAnchor = createTextNode('')), target)
}
+ // track CE teleport targets
+ if (this.parentComponent && this.parentComponent.isCE) {
+ ;(
+ this.parentComponent.ce!._teleportTargets ||
+ (this.parentComponent.ce!._teleportTargets = new Set())
+ ).add(target)
+ }
+
mount(target, this.targetAnchor!)
} else if (__DEV__) {
warn(
diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts
index b104b20900d..5aae0560aec 100644
--- a/packages/runtime-vapor/src/dom/prop.ts
+++ b/packages/runtime-vapor/src/dom/prop.ts
@@ -1,5 +1,6 @@
import {
type NormalizedStyle,
+ camelize,
canSetValueDirectly,
includeBooleanAttr,
isArray,
@@ -37,6 +38,7 @@ import {
} from '../component'
import { isHydrating, logMismatchError } from './hydration'
import type { Block } from '../block'
+import type { VaporElement } from '../apiDefineVaporCustomElement'
type TargetElement = Element & {
$root?: true
@@ -98,6 +100,7 @@ export function setDOMProp(
key: string,
value: any,
forceHydrate: boolean = false,
+ attrName?: string,
): void {
if (!isApplyingFallthroughProps && el.$root && hasFallthroughKey(key)) {
return
@@ -149,7 +152,7 @@ export function setDOMProp(
)
}
}
- needRemove && el.removeAttribute(key)
+ needRemove && el.removeAttribute(attrName || key)
}
export function setClass(el: TargetElement, value: any): void {
@@ -457,6 +460,12 @@ export function setDynamicProp(
} else {
setDOMProp(el, key, value, forceHydrate)
}
+ } else if (
+ // custom elements
+ (el as VaporElement)._isVueCE &&
+ (/[A-Z]/.test(key) || !isString(value))
+ ) {
+ setDOMProp(el, camelize(key), value, forceHydrate, key)
} else {
setAttr(el, key, value)
}
@@ -476,12 +485,12 @@ export function optimizePropertyLookup(): void {
proto.$key = undefined
proto.$fc = proto.$evtclick = undefined
proto.$root = false
- proto.$html =
- proto.$txt =
- proto.$cls =
- proto.$sty =
- (Text.prototype as any).$txt =
- ''
+ proto.$html = proto.$cls = proto.$sty = ''
+ // Initialize $txt to undefined instead of empty string to ensure setText()
+ // properly updates the text node even when the value is empty string.
+ // This prevents issues where setText(node, '') would be skipped because
+ // $txt === '' would return true, leaving the original nodeValue unchanged.
+ ;(Text.prototype as any).$txt = undefined
}
function classHasMismatch(
diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts
index 0d718747d24..98bfda025d0 100644
--- a/packages/runtime-vapor/src/index.ts
+++ b/packages/runtime-vapor/src/index.ts
@@ -6,6 +6,10 @@ export { vaporInteropPlugin } from './vdomInterop'
export type { VaporDirective } from './directives/custom'
export { VaporTeleportImpl as VaporTeleport } from './components/Teleport'
export { VaporKeepAliveImpl as VaporKeepAlive } from './components/KeepAlive'
+export {
+ defineVaporCustomElement,
+ defineVaporSSRCustomElement,
+} from './apiDefineVaporCustomElement'
// compiler-use only
export { insert, prepend, remove } from './block'
@@ -13,6 +17,7 @@ export { setInsertionState } from './insertionState'
export {
createComponent,
createComponentWithFallback,
+ createPlainElement,
isVaporComponent,
} from './component'
export { renderEffect } from './renderEffect'
diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts
index 3c937c0ed58..e36ac4ba458 100644
--- a/packages/runtime-vapor/src/renderEffect.ts
+++ b/packages/runtime-vapor/src/renderEffect.ts
@@ -41,7 +41,9 @@ export class RenderEffect extends ReactiveEffect {
this.onTrigger = instance.rtg
? e => invokeArrayFns(instance.rtg!, e)
: void 0
+ }
+ if (__DEV__ || instance.type.ce) {
// register effect for stopping them during HMR rerender
;(instance.renderEffects || (instance.renderEffects = [])).push(this)
}
diff --git a/packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts b/packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts
new file mode 100644
index 00000000000..c50bff6709c
--- /dev/null
+++ b/packages/vue/__tests__/e2e/ssr-custom-element-vapor.spec.ts
@@ -0,0 +1,128 @@
+import path from 'node:path'
+import fs from 'node:fs'
+import { setupPuppeteer } from './e2eUtils'
+
+const { page, click, text } = setupPuppeteer()
+
+let vaporDataUrl: string
+
+beforeAll(() => {
+ // Read the vapor ESM module once
+ const vaporPath = path.resolve(
+ __dirname,
+ '../../dist/vue.runtime-with-vapor.esm-browser.js',
+ )
+ const vaporCode = fs.readFileSync(vaporPath, 'utf-8')
+
+ // Create a data URL for the ESM module
+ vaporDataUrl = `data:text/javascript;base64,${Buffer.from(vaporCode).toString('base64')}`
+})
+
+async function loadVaporModule() {
+ // Load module and expose to window
+ await page().addScriptTag({
+ content: `
+ import('${vaporDataUrl}').then(module => {
+ window.VueVapor = module;
+ });
+ `,
+ type: 'module',
+ })
+
+ // Wait for VueVapor to be available
+ await page().waitForFunction(
+ () => typeof (window as any).VueVapor !== 'undefined',
+ { timeout: 10000 },
+ )
+}
+
+async function setContent(html: string) {
+ // For SSR content with declarative shadow DOM, we need to use setContent
+ // which causes the browser to parse the HTML properly
+ await page().setContent(`
+
+
+
+ ${html}
+
+
+ `)
+
+ // load the vapor module after setting content
+ await loadVaporModule()
+}
+
+// this must be tested in actual Chrome because jsdom does not support
+// declarative shadow DOM
+test('ssr vapor custom element hydration', async () => {
+ await setContent(
+ ``,
+ )
+
+ await page().evaluate(() => {
+ const {
+ ref,
+ defineVaporSSRCustomElement,
+ defineVaporAsyncComponent,
+ onMounted,
+ useHost,
+ template,
+ child,
+ setText,
+ renderEffect,
+ delegateEvents,
+ } = (window as any).VueVapor
+
+ delegateEvents('click')
+
+ const def = {
+ setup() {
+ const count = ref(1)
+ const el = useHost()
+ onMounted(() => (el.style.border = '1px solid red'))
+
+ const n0 = template('')()
+ const x0 = child(n0)
+ n0.$evtclick = () => count.value++
+ renderEffect(() => setText(x0, count.value))
+ return n0
+ },
+ }
+
+ customElements.define('my-element', defineVaporSSRCustomElement(def))
+ customElements.define(
+ 'my-element-async',
+ defineVaporSSRCustomElement(
+ defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ ;(window as any).resolve = () => r(def)
+ }),
+ ),
+ ),
+ )
+ })
+
+ function getColor() {
+ return page().evaluate(() => {
+ return [
+ (document.querySelector('my-element') as any).style.border,
+ (document.querySelector('my-element-async') as any).style.border,
+ ]
+ })
+ }
+
+ expect(await getColor()).toMatchObject(['1px solid red', ''])
+ await page().evaluate(() => (window as any).resolve()) // exposed by test
+ expect(await getColor()).toMatchObject(['1px solid red', '1px solid red'])
+
+ async function assertInteraction(el: string) {
+ const selector = `${el} >>> button`
+ expect(await text(selector)).toBe('1')
+ await click(selector)
+ expect(await text(selector)).toBe('2')
+ }
+
+ await assertInteraction('my-element')
+ await assertInteraction('my-element-async')
+})
diff --git a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts
index c875f1bee69..c39286d3d12 100644
--- a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts
+++ b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts
@@ -78,49 +78,6 @@ test('ssr custom element hydration', async () => {
await assertInteraction('my-element-async')
})
-test('work with Teleport (shadowRoot: false)', async () => {
- await setContent(
- `default`,
- )
-
- await page().evaluate(() => {
- const { h, defineSSRCustomElement, Teleport, renderSlot } = (window as any)
- .Vue
- const Y = defineSSRCustomElement(
- {
- render() {
- return h(
- Teleport,
- { to: '#test' },
- {
- default: () => [renderSlot(this.$slots, 'default')],
- },
- )
- },
- },
- { shadowRoot: false },
- )
- customElements.define('my-y', Y)
- const P = defineSSRCustomElement(
- {
- render() {
- return renderSlot(this.$slots, 'default')
- },
- },
- { shadowRoot: false },
- )
- customElements.define('my-p', P)
- })
-
- function getInnerHTML() {
- return page().evaluate(() => {
- return (document.querySelector('#test') as any).innerHTML
- })
- }
-
- expect(await getInnerHTML()).toBe('default')
-})
-
// #11641
test('pass key to custom element', async () => {
const messages: string[] = []