* @internal
*/
emitsOptions: ObjectEmitsOptions | null
-
/**
* resolved inheritAttrs options
* @internal
*/
inheritAttrs?: boolean
+ /**
+ * is custom element?
+ */
+ isCE?: boolean
// the rest are only for stateful components ---------------------------------
instance.root = parent ? parent.root : instance
instance.emit = emit.bind(null, instance)
+ // apply custom element special handling
+ if (vnode.ce) {
+ vnode.ce(instance)
+ }
+
return instance
}
import { Data } from '../component'
import { Slots, RawSlots } from '../componentSlots'
-import { ContextualRenderFn } from '../componentRenderContext'
+import {
+ ContextualRenderFn,
+ currentRenderingInstance
+} from '../componentRenderContext'
import { Comment, isVNode } from '../vnode'
import {
VNodeArrayChildren,
} from '../vnode'
import { PatchFlags, SlotFlags } from '@vue/shared'
import { warn } from '../warning'
+import { createVNode } from '@vue/runtime-core'
/**
* Compiler runtime helper for rendering `<slot/>`
fallback?: () => VNodeArrayChildren,
noSlotted?: boolean
): VNode {
+ if (currentRenderingInstance!.isCE) {
+ return createVNode(
+ 'slot',
+ name === 'default' ? null : { name },
+ fallback && fallback()
+ )
+ }
+
let slot = slots[name]
if (__DEV__ && slot && slot.length > 1) {
export type RootHydrateFunction = (
vnode: VNode<Node, Element>,
- container: Element
+ container: Element | ShadowRoot
) => void
const enum DOMNodeTypes {
createApp: CreateAppFunction<HostElement>
}
-export interface HydrationRenderer extends Renderer<Element> {
+export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
hydrate: RootHydrateFunction
}
*/
[ReactiveFlags.SKIP]: true
- /**
- * @internal __COMPAT__ only
- */
- isCompatRoot?: true
-
type: VNodeTypes
props: (VNodeProps & ExtraProps) | null
key: string | number | null
* - Slot fragment vnodes with :slotted SFC styles.
* - Component vnodes (during patch/hydration) so that its root node can
* inherit the component's slotScopeIds
+ * @internal
*/
slotScopeIds: string[] | null
children: VNodeNormalizedChildren
anchor: HostNode | null // fragment anchor
target: HostElement | null // teleport target
targetAnchor: HostNode | null // teleport target anchor
- staticCount?: number // number of elements contained in a static vnode
+ /**
+ * number of elements contained in a static vnode
+ * @internal
+ */
+ staticCount: number
// suspense
suspense: SuspenseBoundary | null
+ /**
+ * @internal
+ */
ssContent: VNode | null
+ /**
+ * @internal
+ */
ssFallback: VNode | null
// optimization only
shapeFlag: number
patchFlag: number
+ /**
+ * @internal
+ */
dynamicProps: string[] | null
+ /**
+ * @internal
+ */
dynamicChildren: VNode[] | null
// application root node only
appContext: AppContext | null
- // v-for memo
+ /**
+ * @internal attached by v-memo
+ */
memo?: any[]
+ /**
+ * @internal __COMPAT__ only
+ */
+ isCompatRoot?: true
+ /**
+ * @internal custom element interception hook
+ */
+ ce?: (instance: ComponentInternalInstance) => void
}
// Since v-if and v-for are the two possible ways node structure can dynamically
--- /dev/null
+import {
+ defineCustomElement,
+ h,
+ nextTick,
+ ref,
+ renderSlot,
+ VueElement
+} from '../src'
+
+describe('defineCustomElement', () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ beforeEach(() => {
+ container.innerHTML = ''
+ })
+
+ describe('mounting/unmount', () => {
+ const E = defineCustomElement({
+ render: () => h('div', 'hello')
+ })
+ customElements.define('my-element', E)
+
+ test('should work', () => {
+ container.innerHTML = `<my-element></my-element>`
+ const e = container.childNodes[0] as VueElement
+ expect(e).toBeInstanceOf(E)
+ expect(e._instance).toBeTruthy()
+ expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
+ })
+
+ test('should work w/ manual instantiation', () => {
+ const e = new E()
+ // should lazy init
+ expect(e._instance).toBe(null)
+ // should initialize on connect
+ container.appendChild(e)
+ expect(e._instance).toBeTruthy()
+ expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
+ })
+
+ test('should unmount on remove', async () => {
+ container.innerHTML = `<my-element></my-element>`
+ const e = container.childNodes[0] as VueElement
+ container.removeChild(e)
+ await nextTick()
+ expect(e._instance).toBe(null)
+ expect(e.shadowRoot!.innerHTML).toBe('')
+ })
+
+ test('should not unmount on move', async () => {
+ container.innerHTML = `<div><my-element></my-element></div>`
+ const e = container.childNodes[0].childNodes[0] as VueElement
+ const i = e._instance
+ // moving from one parent to another - this will trigger both disconnect
+ // and connected callbacks synchronously
+ container.appendChild(e)
+ await nextTick()
+ // should be the same instance
+ expect(e._instance).toBe(i)
+ expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
+ })
+ })
+
+ describe('props', () => {
+ const E = defineCustomElement({
+ props: ['foo', 'bar', 'bazQux'],
+ render() {
+ return [
+ h('div', null, this.foo),
+ h('div', null, this.bazQux || (this.bar && this.bar.x))
+ ]
+ }
+ })
+ customElements.define('my-el-props', E)
+
+ test('props via attribute', async () => {
+ // bazQux should map to `baz-qux` attribute
+ container.innerHTML = `<my-el-props foo="hello" baz-qux="bye"></my-el-props>`
+ const e = container.childNodes[0] as VueElement
+ expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div><div>bye</div>')
+
+ // change attr
+ e.setAttribute('foo', 'changed')
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div><div>bye</div>')
+
+ e.setAttribute('baz-qux', 'changed')
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe(
+ '<div>changed</div><div>changed</div>'
+ )
+ })
+
+ test('props via properties', async () => {
+ const e = new E()
+ e.foo = 'one'
+ e.bar = { x: 'two' }
+ container.appendChild(e)
+ expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')
+
+ e.foo = 'three'
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>two</div>')
+
+ e.bazQux = 'four'
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>four</div>')
+ })
+ })
+
+ describe('emits', () => {
+ const E = defineCustomElement({
+ setup(_, { emit }) {
+ emit('created')
+ return () =>
+ h('div', {
+ onClick: () => emit('my-click', 1)
+ })
+ }
+ })
+ customElements.define('my-el-emits', E)
+
+ test('emit on connect', () => {
+ const e = new E()
+ const spy = jest.fn()
+ e.addEventListener('created', spy)
+ container.appendChild(e)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ test('emit on interaction', () => {
+ container.innerHTML = `<my-el-emits></my-el-emits>`
+ const e = container.childNodes[0] as VueElement
+ const spy = jest.fn()
+ e.addEventListener('my-click', spy)
+ e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click'))
+ expect(spy).toHaveBeenCalled()
+ expect(spy.mock.calls[0][0]).toMatchObject({
+ detail: [1]
+ })
+ })
+ })
+
+ describe('slots', () => {
+ const E = defineCustomElement({
+ 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('default slot', () => {
+ container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
+ const e = container.childNodes[0] as VueElement
+ // native slots allocation does not affect innerHTML, so we just
+ // verify that we've rendered the correct native slots here...
+ expect(e.shadowRoot!.innerHTML).toBe(
+ `<div><slot><div>fallback</div></slot></div><div><slot name="named"></slot></div>`
+ )
+ })
+ })
+
+ describe('provide/inject', () => {
+ const Consumer = defineCustomElement({
+ inject: ['foo'],
+ render(this: any) {
+ return h('div', this.foo.value)
+ }
+ })
+ customElements.define('my-consumer', Consumer)
+
+ test('over nested usage', async () => {
+ const foo = ref('injected!')
+ const Provider = defineCustomElement({
+ provide: {
+ foo
+ },
+ render() {
+ return h('my-consumer')
+ }
+ })
+ customElements.define('my-provider', Provider)
+ container.innerHTML = `<my-provider><my-provider>`
+ const provider = container.childNodes[0] as VueElement
+ const consumer = provider.shadowRoot!.childNodes[0] as VueElement
+
+ expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
+
+ foo.value = 'changed!'
+ await nextTick()
+ expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
+ })
+
+ test('over slot composition', async () => {
+ const foo = ref('injected!')
+ const Provider = defineCustomElement({
+ provide: {
+ foo
+ },
+ render() {
+ return renderSlot(this.$slots, 'default')
+ }
+ })
+ customElements.define('my-provider-2', Provider)
+
+ container.innerHTML = `<my-provider-2><my-consumer></my-consumer><my-provider-2>`
+ const provider = container.childNodes[0]
+ const consumer = provider.childNodes[0] as VueElement
+ expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
+
+ foo.value = 'changed!'
+ await nextTick()
+ expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
+ })
+ })
+})
--- /dev/null
+import {
+ Component,
+ ComponentOptionsMixin,
+ ComponentOptionsWithArrayProps,
+ ComponentOptionsWithObjectProps,
+ ComponentOptionsWithoutProps,
+ ComponentPropsOptions,
+ ComponentPublicInstance,
+ ComputedOptions,
+ EmitsOptions,
+ MethodOptions,
+ RenderFunction,
+ SetupContext,
+ ComponentInternalInstance,
+ VNode,
+ RootHydrateFunction,
+ ExtractPropTypes,
+ createVNode,
+ defineComponent,
+ nextTick,
+ warn
+} from '@vue/runtime-core'
+import { camelize, hyphenate, isArray } from '@vue/shared'
+import { hydrate, render } from '.'
+
+type VueElementConstructor<P = {}> = {
+ new (): VueElement & P
+}
+
+// defineCustomElement provides the same type inference as defineComponent
+// so most of the following overloads should be kept in sync w/ defineComponent.
+
+// overload 1: direct setup function
+export function defineCustomElement<Props, RawBindings = object>(
+ setup: (
+ props: Readonly<Props>,
+ ctx: SetupContext
+ ) => RawBindings | RenderFunction
+): VueElementConstructor<Props>
+
+// overload 2: object format with no props
+export function defineCustomElement<
+ Props = {},
+ RawBindings = {},
+ D = {},
+ C extends ComputedOptions = {},
+ M extends MethodOptions = {},
+ Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
+ Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
+ E extends EmitsOptions = EmitsOptions,
+ EE extends string = string
+>(
+ options: ComponentOptionsWithoutProps<
+ Props,
+ RawBindings,
+ D,
+ C,
+ M,
+ Mixin,
+ Extends,
+ E,
+ EE
+ >
+): VueElementConstructor<Props>
+
+// overload 3: object format with array props declaration
+export function defineCustomElement<
+ PropNames extends string,
+ RawBindings,
+ D,
+ C extends ComputedOptions = {},
+ M extends MethodOptions = {},
+ Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
+ Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
+ E extends EmitsOptions = Record<string, any>,
+ EE extends string = string
+>(
+ options: ComponentOptionsWithArrayProps<
+ PropNames,
+ RawBindings,
+ D,
+ C,
+ M,
+ Mixin,
+ Extends,
+ E,
+ EE
+ >
+): VueElementConstructor<{ [K in PropNames]: any }>
+
+// overload 4: object format with object props declaration
+export function defineCustomElement<
+ PropsOptions extends Readonly<ComponentPropsOptions>,
+ RawBindings,
+ D,
+ C extends ComputedOptions = {},
+ M extends MethodOptions = {},
+ Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
+ Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
+ E extends EmitsOptions = Record<string, any>,
+ EE extends string = string
+>(
+ options: ComponentOptionsWithObjectProps<
+ PropsOptions,
+ RawBindings,
+ D,
+ C,
+ M,
+ Mixin,
+ Extends,
+ E,
+ EE
+ >
+): VueElementConstructor<ExtractPropTypes<PropsOptions>>
+
+// overload 5: defining a custom element from the returned value of
+// `defineComponent`
+export function defineCustomElement(options: {
+ new (...args: any[]): ComponentPublicInstance
+}): VueElementConstructor
+
+export function defineCustomElement(
+ options: any,
+ hydate?: RootHydrateFunction
+): VueElementConstructor {
+ const Comp = defineComponent(options as any)
+ const { props } = options
+ const rawKeys = props ? (isArray(props) ? props : Object.keys(props)) : []
+ const attrKeys = rawKeys.map(hyphenate)
+ const propKeys = rawKeys.map(camelize)
+
+ class VueCustomElement extends VueElement {
+ static get observedAttributes() {
+ return attrKeys
+ }
+ constructor() {
+ super(Comp, attrKeys, hydate)
+ }
+ }
+
+ for (const key of propKeys) {
+ Object.defineProperty(VueCustomElement.prototype, key, {
+ get() {
+ return this._getProp(key)
+ },
+ set(val) {
+ this._setProp(key, val)
+ }
+ })
+ }
+
+ return VueCustomElement
+}
+
+export const defineSSRCustomElement = ((options: any) => {
+ // @ts-ignore
+ return defineCustomElement(options, hydrate)
+}) as typeof defineCustomElement
+
+export class VueElement extends HTMLElement {
+ /**
+ * @internal
+ */
+ _props: Record<string, any> = {}
+ /**
+ * @internal
+ */
+ _instance: ComponentInternalInstance | null = null
+ /**
+ * @internal
+ */
+ _connected = false
+
+ constructor(
+ private _def: Component,
+ private _attrs: string[],
+ hydrate?: RootHydrateFunction
+ ) {
+ super()
+ if (this.shadowRoot && hydrate) {
+ hydrate(this._initVNode(), this.shadowRoot)
+ } else {
+ if (__DEV__ && this.shadowRoot) {
+ warn(
+ `Custom element has pre-rendered declarative shadow root but is not ` +
+ `defined as hydratable. Use \`defineSSRCustomElement\`.`
+ )
+ }
+ this.attachShadow({ mode: 'open' })
+ }
+ }
+
+ attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
+ if (this._attrs.includes(name)) {
+ this._setProp(camelize(name), newValue)
+ }
+ }
+
+ connectedCallback() {
+ this._connected = true
+ if (!this._instance) {
+ render(this._initVNode(), this.shadowRoot!)
+ }
+ }
+
+ disconnectedCallback() {
+ this._connected = false
+ nextTick(() => {
+ if (!this._connected) {
+ render(null, this.shadowRoot!)
+ this._instance = null
+ }
+ })
+ }
+
+ protected _getProp(key: string) {
+ return this._props[key]
+ }
+
+ protected _setProp(key: string, val: any) {
+ const oldValue = this._props[key]
+ this._props[key] = val
+ if (this._instance && val !== oldValue) {
+ this._instance.props[key] = val
+ }
+ }
+
+ protected _initVNode(): VNode<any, any> {
+ const vnode = createVNode(this._def, this._props)
+ vnode.ce = instance => {
+ this._instance = instance
+ instance.isCE = true
+
+ // intercept emit
+ instance.emit = (event: string, ...args: any[]) => {
+ this.dispatchEvent(
+ new CustomEvent(event, {
+ detail: args
+ })
+ )
+ }
+
+ // locate nearest Vue custom element parent for provide/inject
+ let parent: Node | null = this
+ while (
+ (parent = parent && (parent.parentNode || (parent as ShadowRoot).host))
+ ) {
+ if (parent instanceof VueElement) {
+ instance.parent = parent._instance
+ break
+ }
+ }
+ }
+ return vnode
+ }
+}
// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
-let renderer: Renderer<Element> | HydrationRenderer
+let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
let enabledHydration = false
function ensureRenderer() {
- return renderer || (renderer = createRenderer<Node, Element>(rendererOptions))
+ return (
+ renderer ||
+ (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
+ )
}
function ensureHydrationRenderer() {
// use explicit type casts here to avoid import() calls in rolled-up d.ts
export const render = ((...args) => {
ensureRenderer().render(...args)
-}) as RootRenderFunction<Element>
+}) as RootRenderFunction<Element | ShadowRoot>
export const hydrate = ((...args) => {
ensureHydrationRenderer().hydrate(...args)
return container as any
}
+// Custom element support
+export {
+ defineCustomElement,
+ defineSSRCustomElement,
+ VueElement
+} from './apiCustomElement'
+
// SFC CSS utilities
export { useCssModule } from './helpers/useCssModule'
export { useCssVars } from './helpers/useCssVars'