Skip to content

Commit c421fb9

Browse files
committed
feat(runtime-dom): support async component in defineCustomElement
close #4261
1 parent 1994f12 commit c421fb9

File tree

4 files changed

+177
-47
lines changed

4 files changed

+177
-47
lines changed

packages/runtime-core/src/component.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ export interface ComponentInternalInstance {
293293
/**
294294
* custom element specific HMR method
295295
*/
296-
ceReload?: () => void
296+
ceReload?: (newStyles?: string[]) => void
297297

298298
// the rest are only for stateful components ---------------------------------
299299

packages/runtime-core/src/hmr.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,21 @@ function reload(id: string, newComp: ComponentOptions | ClassComponent) {
136136
if (instance.ceReload) {
137137
// custom element
138138
hmrDirtyComponents.add(component)
139-
instance.ceReload()
139+
instance.ceReload((newComp as any).styles)
140140
hmrDirtyComponents.delete(component)
141141
} else if (instance.parent) {
142142
// 4. Force the parent instance to re-render. This will cause all updated
143143
// components to be unmounted and re-mounted. Queue the update so that we
144144
// don't end up forcing the same parent to re-render multiple times.
145145
queueJob(instance.parent.update)
146+
// instance is the inner component of an async custom element
147+
// invoke to reset styles
148+
if (
149+
(instance.parent.type as ComponentOptions).__asyncLoader &&
150+
instance.parent.ceReload
151+
) {
152+
instance.parent.ceReload((newComp as any).styles)
153+
}
146154
} else if (instance.appContext.reload) {
147155
// root instance mounted via createApp() has a reload method
148156
instance.appContext.reload()

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

+93
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
defineAsyncComponent,
23
defineCustomElement,
34
h,
45
inject,
@@ -300,4 +301,96 @@ describe('defineCustomElement', () => {
300301
expect(style.textContent).toBe(`div { color: red; }`)
301302
})
302303
})
304+
305+
describe('async', () => {
306+
test('should work', async () => {
307+
const loaderSpy = jest.fn()
308+
const E = defineCustomElement(
309+
defineAsyncComponent(() => {
310+
loaderSpy()
311+
return Promise.resolve({
312+
props: ['msg'],
313+
styles: [`div { color: red }`],
314+
render(this: any) {
315+
return h('div', null, this.msg)
316+
}
317+
})
318+
})
319+
)
320+
customElements.define('my-el-async', E)
321+
container.innerHTML =
322+
`<my-el-async msg="hello"></my-el-async>` +
323+
`<my-el-async msg="world"></my-el-async>`
324+
325+
await new Promise(r => setTimeout(r))
326+
327+
// loader should be called only once
328+
expect(loaderSpy).toHaveBeenCalledTimes(1)
329+
330+
const e1 = container.childNodes[0] as VueElement
331+
const e2 = container.childNodes[1] as VueElement
332+
333+
// should inject styles
334+
expect(e1.shadowRoot!.innerHTML).toBe(
335+
`<div>hello</div><style>div { color: red }</style>`
336+
)
337+
expect(e2.shadowRoot!.innerHTML).toBe(
338+
`<div>world</div><style>div { color: red }</style>`
339+
)
340+
341+
// attr
342+
e1.setAttribute('msg', 'attr')
343+
await nextTick()
344+
expect((e1 as any).msg).toBe('attr')
345+
expect(e1.shadowRoot!.innerHTML).toBe(
346+
`<div>attr</div><style>div { color: red }</style>`
347+
)
348+
349+
// props
350+
expect(`msg` in e1).toBe(true)
351+
;(e1 as any).msg = 'prop'
352+
expect(e1.getAttribute('msg')).toBe('prop')
353+
expect(e1.shadowRoot!.innerHTML).toBe(
354+
`<div>prop</div><style>div { color: red }</style>`
355+
)
356+
})
357+
358+
test('set DOM property before resolve', async () => {
359+
const E = defineCustomElement(
360+
defineAsyncComponent(() => {
361+
return Promise.resolve({
362+
props: ['msg'],
363+
render(this: any) {
364+
return h('div', this.msg)
365+
}
366+
})
367+
})
368+
)
369+
customElements.define('my-el-async-2', E)
370+
371+
const e1 = new E()
372+
373+
// set property before connect
374+
e1.msg = 'hello'
375+
376+
const e2 = new E()
377+
378+
container.appendChild(e1)
379+
container.appendChild(e2)
380+
381+
// set property after connect but before resolve
382+
e2.msg = 'world'
383+
384+
await new Promise(r => setTimeout(r))
385+
386+
expect(e1.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
387+
expect(e2.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
388+
389+
e1.msg = 'world'
390+
expect(e1.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
391+
392+
e2.msg = 'hello'
393+
expect(e2.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
394+
})
395+
})
303396
})

packages/runtime-dom/src/apiCustomElement.ts

+74-45
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
defineComponent,
1919
nextTick,
2020
warn,
21+
ConcreteComponent,
2122
ComponentOptions
2223
} from '@vue/runtime-core'
2324
import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
@@ -124,32 +125,13 @@ export function defineCustomElement(
124125
hydate?: RootHydrateFunction
125126
): VueElementConstructor {
126127
const Comp = defineComponent(options as any)
127-
const { props } = options
128-
const rawKeys = props ? (isArray(props) ? props : Object.keys(props)) : []
129-
const attrKeys = rawKeys.map(hyphenate)
130-
const propKeys = rawKeys.map(camelize)
131-
132128
class VueCustomElement extends VueElement {
133129
static def = Comp
134-
static get observedAttributes() {
135-
return attrKeys
136-
}
137130
constructor(initialProps?: Record<string, any>) {
138-
super(Comp, initialProps, attrKeys, propKeys, hydate)
131+
super(Comp, initialProps, hydate)
139132
}
140133
}
141134

142-
for (const key of propKeys) {
143-
Object.defineProperty(VueCustomElement.prototype, key, {
144-
get() {
145-
return this._getProp(key)
146-
},
147-
set(val) {
148-
this._setProp(key, val)
149-
}
150-
})
151-
}
152-
153135
return VueCustomElement
154136
}
155137

@@ -162,20 +144,21 @@ const BaseClass = (
162144
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
163145
) as typeof HTMLElement
164146

147+
type InnerComponentDef = ConcreteComponent & { styles?: string[] }
148+
165149
export class VueElement extends BaseClass {
166150
/**
167151
* @internal
168152
*/
169153
_instance: ComponentInternalInstance | null = null
170154

171155
private _connected = false
156+
private _resolved = false
172157
private _styles?: HTMLStyleElement[]
173158

174159
constructor(
175-
private _def: ComponentOptions & { styles?: string[] },
160+
private _def: InnerComponentDef,
176161
private _props: Record<string, any> = {},
177-
private _attrKeys: string[],
178-
private _propKeys: string[],
179162
hydrate?: RootHydrateFunction
180163
) {
181164
super()
@@ -189,27 +172,25 @@ export class VueElement extends BaseClass {
189172
)
190173
}
191174
this.attachShadow({ mode: 'open' })
192-
this._applyStyles()
193175
}
194-
}
195176

196-
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
197-
if (this._attrKeys.includes(name)) {
198-
this._setProp(camelize(name), toNumber(newValue), false)
177+
// set initial attrs
178+
for (let i = 0; i < this.attributes.length; i++) {
179+
this._setAttr(this.attributes[i].name)
199180
}
181+
// watch future attr changes
182+
const observer = new MutationObserver(mutations => {
183+
for (const m of mutations) {
184+
this._setAttr(m.attributeName!)
185+
}
186+
})
187+
observer.observe(this, { attributes: true })
200188
}
201189

202190
connectedCallback() {
203191
this._connected = true
204192
if (!this._instance) {
205-
// check if there are props set pre-upgrade
206-
for (const key of this._propKeys) {
207-
if (this.hasOwnProperty(key)) {
208-
const value = (this as any)[key]
209-
delete (this as any)[key]
210-
this._setProp(key, value)
211-
}
212-
}
193+
this._resolveDef()
213194
render(this._createVNode(), this.shadowRoot!)
214195
}
215196
}
@@ -224,6 +205,50 @@ export class VueElement extends BaseClass {
224205
})
225206
}
226207

208+
/**
209+
* resolve inner component definition (handle possible async component)
210+
*/
211+
private _resolveDef() {
212+
if (this._resolved) {
213+
return
214+
}
215+
216+
const resolve = (def: InnerComponentDef) => {
217+
this._resolved = true
218+
// check if there are props set pre-upgrade or connect
219+
for (const key of Object.keys(this)) {
220+
if (key[0] !== '_') {
221+
this._setProp(key, this[key as keyof this])
222+
}
223+
}
224+
const { props, styles } = def
225+
// defining getter/setters on prototype
226+
const rawKeys = props ? (isArray(props) ? props : Object.keys(props)) : []
227+
for (const key of rawKeys.map(camelize)) {
228+
Object.defineProperty(this, key, {
229+
get() {
230+
return this._getProp(key)
231+
},
232+
set(val) {
233+
this._setProp(key, val)
234+
}
235+
})
236+
}
237+
this._applyStyles(styles)
238+
}
239+
240+
const asyncDef = (this._def as ComponentOptions).__asyncLoader
241+
if (asyncDef) {
242+
asyncDef().then(resolve)
243+
} else {
244+
resolve(this._def)
245+
}
246+
}
247+
248+
protected _setAttr(key: string) {
249+
this._setProp(camelize(key), toNumber(this.getAttribute(key)), false)
250+
}
251+
227252
/**
228253
* @internal
229254
*/
@@ -261,16 +286,20 @@ export class VueElement extends BaseClass {
261286
instance.isCE = true
262287
// HMR
263288
if (__DEV__) {
264-
instance.ceReload = () => {
265-
this._instance = null
266-
// reset styles
289+
instance.ceReload = newStyles => {
290+
// alawys reset styles
267291
if (this._styles) {
268292
this._styles.forEach(s => this.shadowRoot!.removeChild(s))
269293
this._styles.length = 0
270294
}
271-
this._applyStyles()
272-
// reload
273-
render(this._createVNode(), this.shadowRoot!)
295+
this._applyStyles(newStyles)
296+
// if this is an async component, ceReload is called from the inner
297+
// component so no need to reload the async wrapper
298+
if (!(this._def as ComponentOptions).__asyncLoader) {
299+
// reload
300+
this._instance = null
301+
render(this._createVNode(), this.shadowRoot!)
302+
}
274303
}
275304
}
276305

@@ -299,9 +328,9 @@ export class VueElement extends BaseClass {
299328
return vnode
300329
}
301330

302-
private _applyStyles() {
303-
if (this._def.styles) {
304-
this._def.styles.forEach(css => {
331+
private _applyStyles(styles: string[] | undefined) {
332+
if (styles) {
333+
styles.forEach(css => {
305334
const s = document.createElement('style')
306335
s.textContent = css
307336
this.shadowRoot!.appendChild(s)

0 commit comments

Comments
 (0)