fix(custom-element): handle nested customElement mount w/ shadowRoot false (#11861)

close #11851
close #11871
This commit is contained in:
linzhe 2024-09-13 20:18:10 +08:00 committed by GitHub
parent 1d99d61c1b
commit f2d8019188
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 172 additions and 4 deletions

View File

@ -94,6 +94,7 @@ import type { BaseTransitionProps } from './components/BaseTransition'
import type { DefineComponent } from './apiDefineComponent'
import { markAsyncBoundary } from './helpers/useId'
import { isAsyncWrapper } from './apiAsyncComponent'
import type { RendererElement } from './renderer'
export type Data = Record<string, unknown>
@ -1263,4 +1264,8 @@ export interface ComponentCustomElementInterface {
shouldReflect?: boolean,
shouldUpdate?: boolean,
): void
/**
* @internal attached by the nested Teleport when shadowRoot is false.
*/
_teleportTarget?: RendererElement
}

View File

@ -119,6 +119,9 @@ export const TeleportImpl = {
// Teleport *always* has Array children. This is enforced in both the
// compiler and vnode children normalization.
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
if (parentComponent && parentComponent.isCE) {
parentComponent.ce!._teleportTarget = container
}
mountChildren(
children as VNodeArrayChildren,
container,

View File

@ -2,6 +2,7 @@ import type { MockedFunction } from 'vitest'
import {
type HMRRuntime,
type Ref,
Teleport,
type VueElement,
createApp,
defineAsyncComponent,
@ -10,6 +11,7 @@ import {
h,
inject,
nextTick,
onMounted,
provide,
ref,
render,
@ -975,6 +977,113 @@ describe('defineCustomElement', () => {
`<span>default</span>text` + `<!---->` + `<div>fallback</div>`,
)
})
test('render nested customElement w/ shadowRoot false', async () => {
const calls: string[] = []
const Child = defineCustomElement(
{
setup() {
calls.push('child rendering')
onMounted(() => {
calls.push('child mounted')
})
},
render() {
return renderSlot(this.$slots, 'default')
},
},
{ shadowRoot: false },
)
customElements.define('my-child', Child)
const Parent = defineCustomElement(
{
setup() {
calls.push('parent rendering')
onMounted(() => {
calls.push('parent mounted')
})
},
render() {
return renderSlot(this.$slots, 'default')
},
},
{ shadowRoot: false },
)
customElements.define('my-parent', Parent)
const App = {
render() {
return h('my-parent', null, {
default: () => [
h('my-child', null, {
default: () => [h('span', null, 'default')],
}),
],
})
},
}
const app = createApp(App)
app.mount(container)
await nextTick()
const e = container.childNodes[0] as VueElement
expect(e.innerHTML).toBe(
`<my-child data-v-app=""><span>default</span></my-child>`,
)
expect(calls).toEqual([
'parent rendering',
'parent mounted',
'child rendering',
'child mounted',
])
app.unmount()
})
test('render nested Teleport w/ shadowRoot false', async () => {
const target = document.createElement('div')
const Child = defineCustomElement(
{
render() {
return h(
Teleport,
{ to: target },
{
default: () => [renderSlot(this.$slots, 'default')],
},
)
},
},
{ shadowRoot: false },
)
customElements.define('my-el-teleport-child', Child)
const Parent = defineCustomElement(
{
render() {
return renderSlot(this.$slots, 'default')
},
},
{ shadowRoot: false },
)
customElements.define('my-el-teleport-parent', Parent)
const App = {
render() {
return h('my-el-teleport-parent', null, {
default: () => [
h('my-el-teleport-child', null, {
default: () => [h('span', null, 'default')],
}),
],
})
},
}
const app = createApp(App)
app.mount(container)
await nextTick()
expect(target.innerHTML).toBe(`<span>default</span>`)
app.unmount()
})
})
describe('helpers', () => {

View File

@ -221,6 +221,11 @@ export class VueElement
*/
_nonce: string | undefined = this._def.nonce
/**
* @internal
*/
_teleportTarget?: HTMLElement
private _connected = false
private _resolved = false
private _numberProps: Record<string, true> | null = null
@ -272,6 +277,9 @@ export class VueElement
}
connectedCallback(): void {
// avoid resolving component if it's not connected
if (!this.isConnected) return
if (!this.shadowRoot) {
this._parseSlots()
}
@ -322,7 +330,7 @@ export class VueElement
}
// unmount
this._app && this._app.unmount()
this._instance!.ce = undefined
if (this._instance) this._instance.ce = undefined
this._app = this._instance = null
}
})
@ -601,7 +609,7 @@ export class VueElement
}
/**
* Only called when shaddowRoot is false
* Only called when shadowRoot is false
*/
private _parseSlots() {
const slots: VueElement['_slots'] = (this._slots = {})
@ -615,10 +623,10 @@ export class VueElement
}
/**
* Only called when shaddowRoot is false
* Only called when shadowRoot is false
*/
private _renderSlots() {
const outlets = this.querySelectorAll('slot')
const outlets = (this._teleportTarget || this).querySelectorAll('slot')
const scopeId = this._instance!.type.__scopeId
for (let i = 0; i < outlets.length; i++) {
const o = outlets[i] as HTMLSlotElement

View File

@ -78,6 +78,49 @@ test('ssr custom element hydration', async () => {
await assertInteraction('my-element-async')
})
test('work with Teleport (shadowRoot: false)', async () => {
await setContent(
`<div id='test'></div><my-p><my-y><span>default</span></my-y></my-p>`,
)
await page().evaluate(() => {
const { h, defineSSRCustomElement, Teleport, renderSlot } = (window as any)
.Vue
const Y = defineSSRCustomElement(
{
render() {
return h(
Teleport,
{ to: '#test' },
{
default: () => [renderSlot(this.$slots, 'default')],
},
)
},
},
{ shadowRoot: false },
)
customElements.define('my-y', Y)
const P = defineSSRCustomElement(
{
render() {
return renderSlot(this.$slots, 'default')
},
},
{ shadowRoot: false },
)
customElements.define('my-p', P)
})
function getInnerHTML() {
return page().evaluate(() => {
return (document.querySelector('#test') as any).innerHTML
})
}
expect(await getInnerHTML()).toBe('<span>default</span>')
})
// #11641
test('pass key to custom element', async () => {
const messages: string[] = []