]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(custom-element): handle nested customElement mount w/ shadowRoot false (#11861)
authorlinzhe <40790268+linzhe141@users.noreply.github.com>
Fri, 13 Sep 2024 12:18:10 +0000 (20:18 +0800)
committerGitHub <noreply@github.com>
Fri, 13 Sep 2024 12:18:10 +0000 (20:18 +0800)
close #11851
close #11871

packages/runtime-core/src/component.ts
packages/runtime-core/src/components/Teleport.ts
packages/runtime-dom/__tests__/customElement.spec.ts
packages/runtime-dom/src/apiCustomElement.ts
packages/vue/__tests__/e2e/ssr-custom-element.spec.ts

index cccb7280fd4fd303aa1256c6b5aa596909bdbf50..a1ce1de4eb995b1de4a09f456dc2b2a84da8cb40 100644 (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
 }
index 3393b7272bd4ed29a685f93074397507967208a8..d268322cb1277717f570f1d0208c61ae9585fcfc 100644 (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,
index ac66230e32b9ef9171a33452b0fb6aef5ad059be..51113edef69a33984010fa6b36ba6a78135014af 100644 (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', () => {
index 97389c8e6ec9d90c97e21dd8fdb77cc93ec50046..6ddaf89713016db4582689ec2c2dec0cd349b21e 100644 (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
index c39286d3d1274e8efdf749121e907e017a8e368f..c875f1bee69c388b60f0858cb227dbdb80b38e39 100644 (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[] = []