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): handle nested customElement mount w/ shadowRoot false #11861

Merged
merged 18 commits into from
Sep 13, 2024
Merged
5 changes: 5 additions & 0 deletions packages/runtime-core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down Expand Up @@ -1263,4 +1264,8 @@ export interface ComponentCustomElementInterface {
shouldReflect?: boolean,
shouldUpdate?: boolean,
): void
/**
* @internal attached by the nested Teleport when shadowRoot is false.
*/
_teleportTarget?: RendererElement
}
3 changes: 3 additions & 0 deletions packages/runtime-core/src/components/Teleport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
edison1105 marked this conversation as resolved.
Show resolved Hide resolved
parentComponent.ce!._teleportTarget = container
}
mountChildren(
children as VNodeArrayChildren,
container,
Expand Down
109 changes: 109 additions & 0 deletions packages/runtime-dom/__tests__/customElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MockedFunction } from 'vitest'
import {
type HMRRuntime,
type Ref,
Teleport,
type VueElement,
createApp,
defineAsyncComponent,
Expand All @@ -10,6 +11,7 @@ import {
h,
inject,
nextTick,
onMounted,
provide,
ref,
render,
Expand Down Expand Up @@ -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', () => {
Expand Down
16 changes: 12 additions & 4 deletions packages/runtime-dom/src/apiCustomElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
}
})
Expand Down Expand Up @@ -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 = {})
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/vue/__tests__/e2e/ssr-custom-element.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down