diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 3caf0df58..528c3becd 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -279,12 +279,15 @@ export interface ComponentInternalInstance { * @internal */ emitsOptions: ObjectEmitsOptions | null - /** * resolved inheritAttrs options * @internal */ inheritAttrs?: boolean + /** + * is custom element? + */ + isCE?: boolean // the rest are only for stateful components --------------------------------- @@ -519,6 +522,11 @@ export function createComponentInstance( 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 } diff --git a/packages/runtime-core/src/helpers/renderSlot.ts b/packages/runtime-core/src/helpers/renderSlot.ts index 181d49a54..9389cc28a 100644 --- a/packages/runtime-core/src/helpers/renderSlot.ts +++ b/packages/runtime-core/src/helpers/renderSlot.ts @@ -1,6 +1,9 @@ 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, @@ -11,6 +14,7 @@ import { } from '../vnode' import { PatchFlags, SlotFlags } from '@vue/shared' import { warn } from '../warning' +import { createVNode } from '@vue/runtime-core' /** * Compiler runtime helper for rendering `` @@ -25,6 +29,14 @@ export function renderSlot( 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) { diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 50274fcb4..4e5fc8b5d 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -25,7 +25,7 @@ import { isAsyncWrapper } from './apiAsyncComponent' export type RootHydrateFunction = ( vnode: VNode, - container: Element + container: Element | ShadowRoot ) => void const enum DOMNodeTypes { diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index cfa175301..7cf9abc9c 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -93,7 +93,7 @@ export interface Renderer { createApp: CreateAppFunction } -export interface HydrationRenderer extends Renderer { +export interface HydrationRenderer extends Renderer { hydrate: RootHydrateFunction } diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index aceaf3a90..703c20fe9 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -136,11 +136,6 @@ export interface VNode< */ [ReactiveFlags.SKIP]: true - /** - * @internal __COMPAT__ only - */ - isCompatRoot?: true - type: VNodeTypes props: (VNodeProps & ExtraProps) | null key: string | number | null @@ -155,6 +150,7 @@ export interface VNode< * - 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 @@ -167,24 +163,50 @@ export interface VNode< 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 diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts new file mode 100644 index 000000000..d4f86f53c --- /dev/null +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -0,0 +1,224 @@ +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 = `` + const e = container.childNodes[0] as VueElement + expect(e).toBeInstanceOf(E) + expect(e._instance).toBeTruthy() + expect(e.shadowRoot!.innerHTML).toBe(`
hello
`) + }) + + 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(`
hello
`) + }) + + test('should unmount on remove', async () => { + container.innerHTML = `` + 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 = `
` + 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('
hello
') + }) + }) + + 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 = `` + const e = container.childNodes[0] as VueElement + 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
') + + e.foo = 'three' + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
three
two
') + + e.bazQux = 'four' + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe('
three
four
') + }) + }) + + 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 = `` + 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 = `hi` + 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( + `
fallback
` + ) + }) + }) + + 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 = `` + const provider = container.childNodes[0] as VueElement + const consumer = provider.shadowRoot!.childNodes[0] as VueElement + + 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 = defineCustomElement({ + 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 VueElement + expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) + + foo.value = 'changed!' + await nextTick() + expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) + }) + }) +}) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts new file mode 100644 index 000000000..82992ae0e --- /dev/null +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -0,0 +1,256 @@ +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

= { + 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( + setup: ( + props: Readonly, + ctx: SetupContext + ) => RawBindings | RenderFunction +): VueElementConstructor + +// 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 + +// 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, + 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, + RawBindings, + D, + C extends ComputedOptions = {}, + M extends MethodOptions = {}, + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, + E extends EmitsOptions = Record, + EE extends string = string +>( + options: ComponentOptionsWithObjectProps< + PropsOptions, + RawBindings, + D, + C, + M, + Mixin, + Extends, + E, + EE + > +): VueElementConstructor> + +// 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 = {} + /** + * @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 { + 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 + } +} diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts index 2ad8f2b21..257c554a6 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -28,12 +28,15 @@ const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps) // lazy create the renderer - this makes core renderer logic tree-shakable // in case the user only imports reactivity utilities from Vue. -let renderer: Renderer | HydrationRenderer +let renderer: Renderer | HydrationRenderer let enabledHydration = false function ensureRenderer() { - return renderer || (renderer = createRenderer(rendererOptions)) + return ( + renderer || + (renderer = createRenderer(rendererOptions)) + ) } function ensureHydrationRenderer() { @@ -47,7 +50,7 @@ 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 +}) as RootRenderFunction export const hydrate = ((...args) => { ensureHydrationRenderer().hydrate(...args) @@ -191,6 +194,13 @@ function normalizeContainer( 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'