Skip to content

Commit

Permalink
feat(runtime-dom): defineCustomElement
Browse files Browse the repository at this point in the history
  • Loading branch information
yyx990803 committed Jul 16, 2021
1 parent 42ace95 commit 8610e1c
Show file tree
Hide file tree
Showing 8 changed files with 546 additions and 14 deletions.
10 changes: 9 additions & 1 deletion packages/runtime-core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------

Expand Down Expand Up @@ -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
}

Expand Down
14 changes: 13 additions & 1 deletion packages/runtime-core/src/helpers/renderSlot.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 `<slot/>`
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { isAsyncWrapper } from './apiAsyncComponent'

export type RootHydrateFunction = (
vnode: VNode<Node, Element>,
container: Element
container: Element | ShadowRoot
) => void

const enum DOMNodeTypes {
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export interface Renderer<HostElement = RendererElement> {
createApp: CreateAppFunction<HostElement>
}

export interface HydrationRenderer extends Renderer<Element> {
export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
hydrate: RootHydrateFunction
}

Expand Down
36 changes: 29 additions & 7 deletions packages/runtime-core/src/vnode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
224 changes: 224 additions & 0 deletions packages/runtime-dom/__tests__/customElement.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<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>`)
})
})
})
Loading

0 comments on commit 8610e1c

Please sign in to comment.