Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(custom-element): Use asynchronous custom element nesting to avoid errors #9351

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/runtime-dom/__tests__/customElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
h,
inject,
nextTick,
provide,
Ref,
ref,
renderSlot,
Expand Down Expand Up @@ -692,5 +693,77 @@ describe('defineCustomElement', () => {
`<div><slot><div>fallback</div></slot></div><div><slot name="named"></slot></div>`
)
})

test('async & nested custom elements', async () => {
let fooVal: string | undefined = ''
const E = defineCustomElement(
defineAsyncComponent(() => {
return Promise.resolve({
setup(props) {
provide('foo', 'foo')
},
render(this: any) {
return h('div', null, [renderSlot(this.$slots, 'default')])
}
})
})
)

const EChild = defineCustomElement({
setup(props) {
fooVal = inject('foo')
},
render(this: any) {
return h('div', null, 'child')
}
})
customElements.define('my-el-async-nested-ce', E)
customElements.define('slotted-child', EChild)
container.innerHTML = `<my-el-async-nested-ce><div><slotted-child></slotted-child></div></my-el-async-nested-ce>`

await new Promise(r => setTimeout(r))
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot!.innerHTML).toBe(`<div><slot></slot></div>`)
expect(fooVal).toBe('foo')
})
test('async & multiple levels of nested custom elements', async () => {
Comment on lines +729 to +730
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We normally have a blank line between tests.

let fooVal: string | undefined = ''
const E = defineCustomElement(
defineAsyncComponent(() => {
return Promise.resolve({
setup(props) {
provide('foo', 'foo')
},
render(this: any) {
return h('div', null, [renderSlot(this.$slots, 'default')])
}
})
})
)

const EChild = defineCustomElement({
render(this: any) {
return h('div', null, [renderSlot(this.$slots, 'default')])
}
})

const EChild2 = defineCustomElement({
setup(props) {
fooVal = inject('foo')
},
render(this: any) {
return h('div', null, 'child')
}
})
customElements.define('my-el-async-nested-m-ce', E)
customElements.define('slotted-child-m', EChild)
customElements.define('slotted-child2-m', EChild2)
container.innerHTML = `<my-el-async-nested-m-ce><div><slotted-child-m><slotted-child2-m></slotted-child2-m></slotted-child-m></div></my-el-async-nested-m-ce>`

await new Promise(r => setTimeout(r))
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot!.innerHTML).toBe(`<div><slot></slot></div>`)
expect(fooVal).toBe('foo')
})
})
})
51 changes: 37 additions & 14 deletions packages/runtime-dom/src/apiCustomElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ export class VueElement extends BaseClass {
private _numberProps: Record<string, true> | null = null
private _styles?: HTMLStyleElement[]
private _ob?: MutationObserver | null = null
private _ce_parent?: VueElement | null = null
private _ce_children?: VueElement[] | null = null
constructor(
private _def: InnerComponentDef,
private _props: Record<string, any> = {},
Expand Down Expand Up @@ -208,7 +210,31 @@ export class VueElement extends BaseClass {
if (this._resolved) {
this._update()
} else {
this._resolveDef()
let parent: Node | null = this
let isParentResolved = true
// locate nearest Vue custom element parent and set it to '_ce_parent‘
while (
(parent =
parent && (parent.parentNode || (parent as ShadowRoot).host))
) {
if (parent instanceof VueElement) {
this._ce_parent = parent as VueElement
// If the parent's custom element is asynchronous or not yet resolved
if (
(parent._def as ComponentOptions).__asyncLoader ||
!parent._resolved
) {
// Store the current custom element in the parent's _ce_children
// and wait for the parent to be resolved before executing
;(parent._ce_children || (parent._ce_children = [])).push(this)
isParentResolved = false
}
break
}
}
if (isParentResolved) {
this._resolveDef()
}
}
}
}
Expand All @@ -231,8 +257,6 @@ export class VueElement extends BaseClass {
* resolve inner component definition (handle possible async component)
*/
private _resolveDef() {
this._resolved = true

// set initial attrs
for (let i = 0; i < this.attributes.length; i++) {
this._setAttr(this.attributes[i].name)
Expand All @@ -248,6 +272,7 @@ export class VueElement extends BaseClass {
this._ob.observe(this, { attributes: true })

const resolve = (def: InnerComponentDef, isAsync = false) => {
this._resolved = true
const { props, styles } = def

// cast Number-type props set before resolve
Expand Down Expand Up @@ -278,6 +303,12 @@ export class VueElement extends BaseClass {

// initial render
this._update()

// The asynchronous custom element needs to call
// the resolveDef function of the descendant custom element at the end.
if (this._ce_children) {
this._ce_children.forEach(child => child._resolveDef())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about what happens if children are removed from the DOM:

  • Should we set this._ce_children back to null once we're done, so references to the children aren't retained indefinitely?
  • Do we need to check that the children haven't been removed while the parent was resolving?
  • Perhaps children should remove themselves from this array when they are removed from the DOM?

Here's an example. The async component has a child, which is removed before the async component resolves. But because the component is in this._ce_children it still gets mounted:

The logging shows that the child is still mounted, which doesn't seem right.

I think it's also worth noting that there's a similar problem when the async component itself is removed. This problem already exists on main:

The problem in this second example is probably outside the scope of this PR, though maybe both of these examples could be fixed in the same way? I'm not sure.

}
}

const asyncDef = (this._def as ComponentOptions).__asyncLoader
Expand Down Expand Up @@ -397,17 +428,9 @@ export class VueElement extends BaseClass {
}
}

// 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
instance.provides = parent._instance!.provides
break
}
if (this._ce_parent) {
instance.parent = this._ce_parent._instance
instance.provides = this._ce_parent._instance!.provides
}
}
}
Expand Down