Skip to content

Commit 8610e1c

Browse files
committed
feat(runtime-dom): defineCustomElement
1 parent 42ace95 commit 8610e1c

File tree

8 files changed

+546
-14
lines changed

8 files changed

+546
-14
lines changed

packages/runtime-core/src/component.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,12 +279,15 @@ export interface ComponentInternalInstance {
279279
* @internal
280280
*/
281281
emitsOptions: ObjectEmitsOptions | null
282-
283282
/**
284283
* resolved inheritAttrs options
285284
* @internal
286285
*/
287286
inheritAttrs?: boolean
287+
/**
288+
* is custom element?
289+
*/
290+
isCE?: boolean
288291

289292
// the rest are only for stateful components ---------------------------------
290293

@@ -519,6 +522,11 @@ export function createComponentInstance(
519522
instance.root = parent ? parent.root : instance
520523
instance.emit = emit.bind(null, instance)
521524

525+
// apply custom element special handling
526+
if (vnode.ce) {
527+
vnode.ce(instance)
528+
}
529+
522530
return instance
523531
}
524532

packages/runtime-core/src/helpers/renderSlot.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Data } from '../component'
22
import { Slots, RawSlots } from '../componentSlots'
3-
import { ContextualRenderFn } from '../componentRenderContext'
3+
import {
4+
ContextualRenderFn,
5+
currentRenderingInstance
6+
} from '../componentRenderContext'
47
import { Comment, isVNode } from '../vnode'
58
import {
69
VNodeArrayChildren,
@@ -11,6 +14,7 @@ import {
1114
} from '../vnode'
1215
import { PatchFlags, SlotFlags } from '@vue/shared'
1316
import { warn } from '../warning'
17+
import { createVNode } from '@vue/runtime-core'
1418

1519
/**
1620
* Compiler runtime helper for rendering `<slot/>`
@@ -25,6 +29,14 @@ export function renderSlot(
2529
fallback?: () => VNodeArrayChildren,
2630
noSlotted?: boolean
2731
): VNode {
32+
if (currentRenderingInstance!.isCE) {
33+
return createVNode(
34+
'slot',
35+
name === 'default' ? null : { name },
36+
fallback && fallback()
37+
)
38+
}
39+
2840
let slot = slots[name]
2941

3042
if (__DEV__ && slot && slot.length > 1) {

packages/runtime-core/src/hydration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { isAsyncWrapper } from './apiAsyncComponent'
2525

2626
export type RootHydrateFunction = (
2727
vnode: VNode<Node, Element>,
28-
container: Element
28+
container: Element | ShadowRoot
2929
) => void
3030

3131
const enum DOMNodeTypes {

packages/runtime-core/src/renderer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export interface Renderer<HostElement = RendererElement> {
9393
createApp: CreateAppFunction<HostElement>
9494
}
9595

96-
export interface HydrationRenderer extends Renderer<Element> {
96+
export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
9797
hydrate: RootHydrateFunction
9898
}
9999

packages/runtime-core/src/vnode.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,6 @@ export interface VNode<
136136
*/
137137
[ReactiveFlags.SKIP]: true
138138

139-
/**
140-
* @internal __COMPAT__ only
141-
*/
142-
isCompatRoot?: true
143-
144139
type: VNodeTypes
145140
props: (VNodeProps & ExtraProps) | null
146141
key: string | number | null
@@ -155,6 +150,7 @@ export interface VNode<
155150
* - Slot fragment vnodes with :slotted SFC styles.
156151
* - Component vnodes (during patch/hydration) so that its root node can
157152
* inherit the component's slotScopeIds
153+
* @internal
158154
*/
159155
slotScopeIds: string[] | null
160156
children: VNodeNormalizedChildren
@@ -167,24 +163,50 @@ export interface VNode<
167163
anchor: HostNode | null // fragment anchor
168164
target: HostElement | null // teleport target
169165
targetAnchor: HostNode | null // teleport target anchor
170-
staticCount?: number // number of elements contained in a static vnode
166+
/**
167+
* number of elements contained in a static vnode
168+
* @internal
169+
*/
170+
staticCount: number
171171

172172
// suspense
173173
suspense: SuspenseBoundary | null
174+
/**
175+
* @internal
176+
*/
174177
ssContent: VNode | null
178+
/**
179+
* @internal
180+
*/
175181
ssFallback: VNode | null
176182

177183
// optimization only
178184
shapeFlag: number
179185
patchFlag: number
186+
/**
187+
* @internal
188+
*/
180189
dynamicProps: string[] | null
190+
/**
191+
* @internal
192+
*/
181193
dynamicChildren: VNode[] | null
182194

183195
// application root node only
184196
appContext: AppContext | null
185197

186-
// v-for memo
198+
/**
199+
* @internal attached by v-memo
200+
*/
187201
memo?: any[]
202+
/**
203+
* @internal __COMPAT__ only
204+
*/
205+
isCompatRoot?: true
206+
/**
207+
* @internal custom element interception hook
208+
*/
209+
ce?: (instance: ComponentInternalInstance) => void
188210
}
189211

190212
// Since v-if and v-for are the two possible ways node structure can dynamically
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {
2+
defineCustomElement,
3+
h,
4+
nextTick,
5+
ref,
6+
renderSlot,
7+
VueElement
8+
} from '../src'
9+
10+
describe('defineCustomElement', () => {
11+
const container = document.createElement('div')
12+
document.body.appendChild(container)
13+
14+
beforeEach(() => {
15+
container.innerHTML = ''
16+
})
17+
18+
describe('mounting/unmount', () => {
19+
const E = defineCustomElement({
20+
render: () => h('div', 'hello')
21+
})
22+
customElements.define('my-element', E)
23+
24+
test('should work', () => {
25+
container.innerHTML = `<my-element></my-element>`
26+
const e = container.childNodes[0] as VueElement
27+
expect(e).toBeInstanceOf(E)
28+
expect(e._instance).toBeTruthy()
29+
expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
30+
})
31+
32+
test('should work w/ manual instantiation', () => {
33+
const e = new E()
34+
// should lazy init
35+
expect(e._instance).toBe(null)
36+
// should initialize on connect
37+
container.appendChild(e)
38+
expect(e._instance).toBeTruthy()
39+
expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
40+
})
41+
42+
test('should unmount on remove', async () => {
43+
container.innerHTML = `<my-element></my-element>`
44+
const e = container.childNodes[0] as VueElement
45+
container.removeChild(e)
46+
await nextTick()
47+
expect(e._instance).toBe(null)
48+
expect(e.shadowRoot!.innerHTML).toBe('')
49+
})
50+
51+
test('should not unmount on move', async () => {
52+
container.innerHTML = `<div><my-element></my-element></div>`
53+
const e = container.childNodes[0].childNodes[0] as VueElement
54+
const i = e._instance
55+
// moving from one parent to another - this will trigger both disconnect
56+
// and connected callbacks synchronously
57+
container.appendChild(e)
58+
await nextTick()
59+
// should be the same instance
60+
expect(e._instance).toBe(i)
61+
expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
62+
})
63+
})
64+
65+
describe('props', () => {
66+
const E = defineCustomElement({
67+
props: ['foo', 'bar', 'bazQux'],
68+
render() {
69+
return [
70+
h('div', null, this.foo),
71+
h('div', null, this.bazQux || (this.bar && this.bar.x))
72+
]
73+
}
74+
})
75+
customElements.define('my-el-props', E)
76+
77+
test('props via attribute', async () => {
78+
// bazQux should map to `baz-qux` attribute
79+
container.innerHTML = `<my-el-props foo="hello" baz-qux="bye"></my-el-props>`
80+
const e = container.childNodes[0] as VueElement
81+
expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div><div>bye</div>')
82+
83+
// change attr
84+
e.setAttribute('foo', 'changed')
85+
await nextTick()
86+
expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div><div>bye</div>')
87+
88+
e.setAttribute('baz-qux', 'changed')
89+
await nextTick()
90+
expect(e.shadowRoot!.innerHTML).toBe(
91+
'<div>changed</div><div>changed</div>'
92+
)
93+
})
94+
95+
test('props via properties', async () => {
96+
const e = new E()
97+
e.foo = 'one'
98+
e.bar = { x: 'two' }
99+
container.appendChild(e)
100+
expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')
101+
102+
e.foo = 'three'
103+
await nextTick()
104+
expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>two</div>')
105+
106+
e.bazQux = 'four'
107+
await nextTick()
108+
expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>four</div>')
109+
})
110+
})
111+
112+
describe('emits', () => {
113+
const E = defineCustomElement({
114+
setup(_, { emit }) {
115+
emit('created')
116+
return () =>
117+
h('div', {
118+
onClick: () => emit('my-click', 1)
119+
})
120+
}
121+
})
122+
customElements.define('my-el-emits', E)
123+
124+
test('emit on connect', () => {
125+
const e = new E()
126+
const spy = jest.fn()
127+
e.addEventListener('created', spy)
128+
container.appendChild(e)
129+
expect(spy).toHaveBeenCalled()
130+
})
131+
132+
test('emit on interaction', () => {
133+
container.innerHTML = `<my-el-emits></my-el-emits>`
134+
const e = container.childNodes[0] as VueElement
135+
const spy = jest.fn()
136+
e.addEventListener('my-click', spy)
137+
e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click'))
138+
expect(spy).toHaveBeenCalled()
139+
expect(spy.mock.calls[0][0]).toMatchObject({
140+
detail: [1]
141+
})
142+
})
143+
})
144+
145+
describe('slots', () => {
146+
const E = defineCustomElement({
147+
render() {
148+
return [
149+
h('div', null, [
150+
renderSlot(this.$slots, 'default', undefined, () => [
151+
h('div', 'fallback')
152+
])
153+
]),
154+
h('div', null, renderSlot(this.$slots, 'named'))
155+
]
156+
}
157+
})
158+
customElements.define('my-el-slots', E)
159+
160+
test('default slot', () => {
161+
container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
162+
const e = container.childNodes[0] as VueElement
163+
// native slots allocation does not affect innerHTML, so we just
164+
// verify that we've rendered the correct native slots here...
165+
expect(e.shadowRoot!.innerHTML).toBe(
166+
`<div><slot><div>fallback</div></slot></div><div><slot name="named"></slot></div>`
167+
)
168+
})
169+
})
170+
171+
describe('provide/inject', () => {
172+
const Consumer = defineCustomElement({
173+
inject: ['foo'],
174+
render(this: any) {
175+
return h('div', this.foo.value)
176+
}
177+
})
178+
customElements.define('my-consumer', Consumer)
179+
180+
test('over nested usage', async () => {
181+
const foo = ref('injected!')
182+
const Provider = defineCustomElement({
183+
provide: {
184+
foo
185+
},
186+
render() {
187+
return h('my-consumer')
188+
}
189+
})
190+
customElements.define('my-provider', Provider)
191+
container.innerHTML = `<my-provider><my-provider>`
192+
const provider = container.childNodes[0] as VueElement
193+
const consumer = provider.shadowRoot!.childNodes[0] as VueElement
194+
195+
expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
196+
197+
foo.value = 'changed!'
198+
await nextTick()
199+
expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
200+
})
201+
202+
test('over slot composition', async () => {
203+
const foo = ref('injected!')
204+
const Provider = defineCustomElement({
205+
provide: {
206+
foo
207+
},
208+
render() {
209+
return renderSlot(this.$slots, 'default')
210+
}
211+
})
212+
customElements.define('my-provider-2', Provider)
213+
214+
container.innerHTML = `<my-provider-2><my-consumer></my-consumer><my-provider-2>`
215+
const provider = container.childNodes[0]
216+
const consumer = provider.childNodes[0] as VueElement
217+
expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
218+
219+
foo.value = 'changed!'
220+
await nextTick()
221+
expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
222+
})
223+
})
224+
})

0 commit comments

Comments
 (0)