Skip to content

Commit 00f0b3c

Browse files
committed
feat: custom element reflection, casting and edge cases
1 parent bf4893c commit 00f0b3c

File tree

2 files changed

+116
-34
lines changed

2 files changed

+116
-34
lines changed

packages/runtime-dom/__tests__/customElement.spec.ts

+54-1
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,66 @@ describe('defineCustomElement', () => {
9999
container.appendChild(e)
100100
expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')
101101

102+
// reflect
103+
// should reflect primitive value
104+
expect(e.getAttribute('foo')).toBe('one')
105+
// should not reflect rich data
106+
expect(e.hasAttribute('bar')).toBe(false)
107+
102108
e.foo = 'three'
103109
await nextTick()
104110
expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>two</div>')
111+
expect(e.getAttribute('foo')).toBe('three')
112+
113+
e.foo = null
114+
await nextTick()
115+
expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>two</div>')
116+
expect(e.hasAttribute('foo')).toBe(false)
105117

106118
e.bazQux = 'four'
107119
await nextTick()
108-
expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>four</div>')
120+
expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>four</div>')
121+
expect(e.getAttribute('baz-qux')).toBe('four')
122+
})
123+
124+
test('attribute -> prop type casting', async () => {
125+
const E = defineCustomElement({
126+
props: {
127+
foo: Number,
128+
bar: Boolean
129+
},
130+
render() {
131+
return [this.foo, typeof this.foo, this.bar, typeof this.bar].join(
132+
' '
133+
)
134+
}
135+
})
136+
customElements.define('my-el-props-cast', E)
137+
container.innerHTML = `<my-el-props-cast foo="1"></my-el-props-cast>`
138+
const e = container.childNodes[0] as VueElement
139+
expect(e.shadowRoot!.innerHTML).toBe(`1 number false boolean`)
140+
141+
e.setAttribute('bar', '')
142+
await nextTick()
143+
expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean`)
144+
145+
e.setAttribute('foo', '2e1')
146+
await nextTick()
147+
expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean`)
148+
})
149+
150+
test('handling properties set before upgrading', () => {
151+
const E = defineCustomElement({
152+
props: ['foo'],
153+
render() {
154+
return `foo: ${this.foo}`
155+
}
156+
})
157+
const el = document.createElement('my-el-upgrade') as any
158+
el.foo = 'hello'
159+
container.appendChild(el)
160+
customElements.define('my-el-upgrade', E)
161+
expect(el.shadowRoot.innerHTML).toBe(`foo: hello`)
109162
})
110163
})
111164

packages/runtime-dom/src/apiCustomElement.ts

+62-33
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
nextTick,
2121
warn
2222
} from '@vue/runtime-core'
23-
import { camelize, hyphenate, isArray } from '@vue/shared'
23+
import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
2424
import { hydrate, render } from '.'
2525

2626
type VueElementConstructor<P = {}> = {
@@ -134,7 +134,7 @@ export function defineCustomElement(
134134
return attrKeys
135135
}
136136
constructor() {
137-
super(Comp, attrKeys, hydate)
137+
super(Comp, attrKeys, propKeys, hydate)
138138
}
139139
}
140140

@@ -173,12 +173,13 @@ export class VueElement extends HTMLElement {
173173

174174
constructor(
175175
private _def: Component,
176-
private _attrs: string[],
176+
private _attrKeys: string[],
177+
private _propKeys: string[],
177178
hydrate?: RootHydrateFunction
178179
) {
179180
super()
180181
if (this.shadowRoot && hydrate) {
181-
hydrate(this._initVNode(), this.shadowRoot)
182+
hydrate(this._createVNode(), this.shadowRoot)
182183
} else {
183184
if (__DEV__ && this.shadowRoot) {
184185
warn(
@@ -191,15 +192,23 @@ export class VueElement extends HTMLElement {
191192
}
192193

193194
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
194-
if (this._attrs.includes(name)) {
195-
this._setProp(camelize(name), newValue)
195+
if (this._attrKeys.includes(name)) {
196+
this._setProp(camelize(name), toNumber(newValue), false)
196197
}
197198
}
198199

199200
connectedCallback() {
200201
this._connected = true
201202
if (!this._instance) {
202-
render(this._initVNode(), this.shadowRoot!)
203+
// check if there are props set pre-upgrade
204+
for (const key of this._propKeys) {
205+
if (this.hasOwnProperty(key)) {
206+
const value = (this as any)[key]
207+
delete (this as any)[key]
208+
this._setProp(key, value)
209+
}
210+
}
211+
render(this._createVNode(), this.shadowRoot!)
203212
}
204213
}
205214

@@ -213,41 +222,61 @@ export class VueElement extends HTMLElement {
213222
})
214223
}
215224

225+
/**
226+
* @internal
227+
*/
216228
protected _getProp(key: string) {
217229
return this._props[key]
218230
}
219231

220-
protected _setProp(key: string, val: any) {
221-
const oldValue = this._props[key]
222-
this._props[key] = val
223-
if (this._instance && val !== oldValue) {
224-
this._instance.props[key] = val
232+
/**
233+
* @internal
234+
*/
235+
protected _setProp(key: string, val: any, shouldReflect = true) {
236+
if (val !== this._props[key]) {
237+
this._props[key] = val
238+
if (this._instance) {
239+
render(this._createVNode(), this.shadowRoot!)
240+
}
241+
// reflect
242+
if (shouldReflect) {
243+
if (val === true) {
244+
this.setAttribute(hyphenate(key), '')
245+
} else if (typeof val === 'string' || typeof val === 'number') {
246+
this.setAttribute(hyphenate(key), val + '')
247+
} else if (!val) {
248+
this.removeAttribute(hyphenate(key))
249+
}
250+
}
225251
}
226252
}
227253

228-
protected _initVNode(): VNode<any, any> {
229-
const vnode = createVNode(this._def, this._props)
230-
vnode.ce = instance => {
231-
this._instance = instance
232-
instance.isCE = true
254+
private _createVNode(): VNode<any, any> {
255+
const vnode = createVNode(this._def, extend({}, this._props))
256+
if (!this._instance) {
257+
vnode.ce = instance => {
258+
this._instance = instance
259+
instance.isCE = true
233260

234-
// intercept emit
235-
instance.emit = (event: string, ...args: any[]) => {
236-
this.dispatchEvent(
237-
new CustomEvent(event, {
238-
detail: args
239-
})
240-
)
241-
}
261+
// intercept emit
262+
instance.emit = (event: string, ...args: any[]) => {
263+
this.dispatchEvent(
264+
new CustomEvent(event, {
265+
detail: args
266+
})
267+
)
268+
}
242269

243-
// locate nearest Vue custom element parent for provide/inject
244-
let parent: Node | null = this
245-
while (
246-
(parent = parent && (parent.parentNode || (parent as ShadowRoot).host))
247-
) {
248-
if (parent instanceof VueElement) {
249-
instance.parent = parent._instance
250-
break
270+
// locate nearest Vue custom element parent for provide/inject
271+
let parent: Node | null = this
272+
while (
273+
(parent =
274+
parent && (parent.parentNode || (parent as ShadowRoot).host))
275+
) {
276+
if (parent instanceof VueElement) {
277+
instance.parent = parent._instance
278+
break
279+
}
251280
}
252281
}
253282
}

0 commit comments

Comments
 (0)