diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index cccb7280fd4..a1ce1de4eb9 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -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 @@ -1263,4 +1264,8 @@ export interface ComponentCustomElementInterface { shouldReflect?: boolean, shouldUpdate?: boolean, ): void + /** + * @internal attached by the nested Teleport when shadowRoot is false. + */ + _teleportTarget?: RendererElement } diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts index 3393b7272bd..d268322cb12 100644 --- a/packages/runtime-core/src/components/Teleport.ts +++ b/packages/runtime-core/src/components/Teleport.ts @@ -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, diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index ac66230e32b..51113edef69 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -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', () => { `defaulttext` + `` + `
fallback
`, ) }) + + 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( + `default`, + ) + 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(`default`) + app.unmount() + }) }) describe('helpers', () => { diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 97389c8e6ec..6ddaf897130 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -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 | 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 diff --git a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts index c39286d3d12..c875f1bee69 100644 --- a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts +++ b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts @@ -78,6 +78,49 @@ test('ssr custom element hydration', async () => { await assertInteraction('my-element-async') }) +test('work with Teleport (shadowRoot: false)', async () => { + await setContent( + `
default`, + ) + + 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('default') +}) + // #11641 test('pass key to custom element', async () => { const messages: string[] = []