Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

linzhe141
Copy link
Contributor

@linzhe141 linzhe141 commented Sep 17, 2025

close #13899

Additionally, when two Teleports are used inside ce-component, only one of them renders correctly.

<template>
  <div class="widget">
    <Teleport to="#portal">
      <div class="widget__header">
        header start
        <slot name="headerContent" />
        header end
      </div>
    </Teleport>

    <Teleport to="#portal1">
      <div class="widget__body">
        body start
        <slot name="bodyContent" />
        body end
      </div>
    </Teleport>
  </div>
</template>

issue playground

this pr playground

Summary by CodeRabbit

  • New Features

    • Custom elements can teleport content to multiple targets (not just one).
    • Slot projection now aggregates slots from all teleport targets and the host element when not using Shadow DOM, improving multi-outlet content distribution.
  • Tests

    • Added tests verifying rendering of multiple Teleport instances into separate targets, including a disabled-target variant.

Copy link

coderabbitai bot commented Sep 17, 2025

Walkthrough

Update custom element teleport handling to track multiple teleport targets (as a Set) instead of a single target, push targets from Teleport mounts into the CE's _teleportTargets, and aggregate slot collection across the host and all teleport targets when shadowRoot: false.

Changes

Cohort / File(s) Summary
Public interface updates
packages/runtime-core/src/component.ts, packages/runtime-dom/src/apiCustomElement.ts
Replace CE property _teleportTarget?: RendererElement / HTMLElement with `_teleportTargets?: Set<RendererElement
Teleport CE target aggregation
packages/runtime-core/src/components/Teleport.ts
When mounting Teleport inside a custom element, add the resolved teleport target to parentComponent.ce._teleportTargets (create the Set if needed) instead of assigning a single property.
Custom element slot collection and utilities
packages/runtime-dom/src/apiCustomElement.ts
Replace direct single-root slot query with new _getSlots() that aggregates <slot> elements from the host plus all roots in this._teleportTargets (when shadowRoot: false); update _renderSlots to use aggregated outlets.
Tests
packages/runtime-dom/__tests__/customElement.spec.ts
Add tests validating two Teleport instances render into two external DOM targets for shadowRoot: false custom elements (including a disabled-first-teleport variant).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Page
  participant CE as VueElement (CustomElement)
  participant Teleport
  participant DOM as Browser DOM

  Page->>CE: mount (shadowRoot: false)
  CE->>Teleport: render Teleport nodes
  Teleport->>CE: resolve target
  CE->>CE: add resolved target to _teleportTargets (Set)
  note right of CE #DFF2E1: Multiple Teleports → multiple targets tracked

  Page->>CE: update / re-render
  CE->>CE: _getSlots() aggregates slots from host + _teleportTargets
  CE->>DOM: query aggregated roots for `<slot>` outlets
  CE->>DOM: project content into matching slots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

scope: teleport

Suggested reviewers

  • edison1105

Poem

A rabbit hops between each spot,
I count the burrows — not just one dot.
I gather slots from near and far,
Carrots land in every jar. 🥕
Hop, register, and now we’re done.

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title succinctly and accurately summarizes the primary change: fixing the mounting of multiple Teleports inside custom elements when shadowRoot is false. It is concise, single-sentence, and clearly references the affected area and behavior, so it conveys the main purpose of the changeset.
Linked Issues Check ✅ Passed The changes implement the linked issue's fixes: apiCustomElement now aggregates slot outlets from the host and all teleport targets, TeleportImpl records resolved targets onto the parent custom element as a Set, and tests were added to verify two Teleports render into two external targets (including a disabled variant). These edits address the regression described in #13899 by ensuring slot collection checks both host and teleport roots and enabling multiple Teleports inside custom elements with shadowRoot: false. Based on the provided summaries, the code changes satisfy the coding objectives in the linked issue.
Out of Scope Changes Check ✅ Passed All modified files and added tests are directly related to the linked issue's objectives: tracking multiple teleport targets and updating slot collection for custom elements with shadowRoot: false. The only notable public-facing change is renaming _teleportTarget to _teleportTargets: Set in component and DOM custom-element typings, which is an intentional, targeted change required to support multiple targets rather than an unrelated modification. I found no unrelated or extraneous code changes outside the scope of the bugfix.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

github-actions bot commented Sep 17, 2025

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 101 kB (+247 B) 38.5 kB (+59 B) 34.7 kB (+46 B)
vue.global.prod.js 159 kB (+247 B) 58.6 kB (+69 B) 52.2 kB (+73 B)

Usages

Name Size Gzip Brotli
createApp (CAPI only) 46.6 kB 18.2 kB 16.7 kB
createApp 54.6 kB 21.3 kB 19.4 kB
createSSRApp 58.9 kB 23 kB 21 kB
defineCustomElement 59.8 kB (+210 B) 22.9 kB (+60 B) 20.9 kB (+45 B)
overall 68.8 kB 26.4 kB 24.2 kB

Copy link

pkg-pr-new bot commented Sep 17, 2025

Open in StackBlitz

@vue/compiler-core

npm i https://pkg.pr.new/@vue/compiler-core@13900

@vue/compiler-dom

npm i https://pkg.pr.new/@vue/compiler-dom@13900

@vue/compiler-sfc

npm i https://pkg.pr.new/@vue/compiler-sfc@13900

@vue/compiler-ssr

npm i https://pkg.pr.new/@vue/compiler-ssr@13900

@vue/reactivity

npm i https://pkg.pr.new/@vue/reactivity@13900

@vue/runtime-core

npm i https://pkg.pr.new/@vue/runtime-core@13900

@vue/runtime-dom

npm i https://pkg.pr.new/@vue/runtime-dom@13900

@vue/server-renderer

npm i https://pkg.pr.new/@vue/server-renderer@13900

@vue/shared

npm i https://pkg.pr.new/@vue/shared@13900

vue

npm i https://pkg.pr.new/vue@13900

@vue/compat

npm i https://pkg.pr.new/@vue/compat@13900

commit: db72045

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/runtime-dom/src/apiCustomElement.ts (1)

638-664: Dedup outlets and guard detached nodes to avoid double-processing and crashes

If a teleport target is inside the host, current logic can collect the same twice (via host and target), leading to null parentNode and runtime errors. Dedup and skip detached.

Apply:

-    const outlets = [...getSlots(this._teleportTargets), ...getSlots([this])]
+    const seen = new Set<HTMLSlotElement>()
+    const outlets = [...getSlots(this._teleportTargets), ...getSlots([this])].filter(o => {
+      if (seen.has(o)) return false
+      seen.add(o)
+      return true
+    })
@@
-      const o = outlets[i] as HTMLSlotElement
+      const o = outlets[i] as HTMLSlotElement
-      const parent = o.parentNode!
+      const parent = o.parentNode as ParentNode | null
+      if (!parent) continue
packages/runtime-core/src/components/Teleport.ts (1)

265-285: Missing: update CE targets on Teleport retargeting

When to changes, CE target list isn’t updated; slots inside the new target won’t be discovered.

Apply:

         if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) {
           const nextTarget = (n2.target = resolveTarget(
             n2.props,
             querySelector,
           ))
+          // keep CE teleportTargets in sync
+          if (parentComponent && parentComponent.isCE) {
+            const ce = parentComponent.ce!
+            const list = ce._teleportTargets
+            if (list) {
+              const idx = list.indexOf(target)
+              if (idx > -1) list.splice(idx, 1)
+            }
+            if (nextTarget) {
+              const arr = (ce._teleportTargets ||= [])
+              if (!arr.includes(nextTarget)) arr.push(nextTarget)
+            }
+          }
           if (nextTarget) {
             moveTeleport(
🧹 Nitpick comments (3)
packages/runtime-dom/src/apiCustomElement.ts (2)

227-227: Broaden target type: HTMLElement[] → Element[] (SVG/MathML-safe)

Teleport targets can be SVGElement/MathMLElement. Narrowing to HTMLElement[] is brittle; use Element[] to match RendererElement.

Apply:

-  _teleportTargets?: HTMLElement[]
+  _teleportTargets?: Element[]

720-726: Make getSlots accept Element[] and skip disconnected roots

Align with broadened type and avoid unnecessary queries on stale containers.

Apply:

-function getSlots(roots: HTMLElement[] | undefined): HTMLSlotElement[] {
-  if (!roots) return []
-  return roots.reduce<HTMLSlotElement[]>((res, i) => {
-    res.push(...Array.from(i.querySelectorAll('slot')))
-    return res
-  }, [])
-}
+function getSlots(roots: Element[] | undefined): HTMLSlotElement[] {
+  if (!roots) return []
+  return roots
+    .filter(r => (r as any).isConnected !== false)
+    .reduce<HTMLSlotElement[]>((res, el) => {
+      res.push(...Array.from(el.querySelectorAll('slot')))
+      return res
+    }, [])
+}
packages/runtime-core/src/components/Teleport.ts (1)

318-337: Missing: cleanup CE targets on Teleport unmount

Stale containers remain in _teleportTargets, causing unnecessary scans and potential duplicates later.

Apply:

     if (target) {
       hostRemove(targetStart!)
       hostRemove(targetAnchor!)
     }
+    // remove from CE target list if applicable
+    if (parentComponent && parentComponent.isCE && target) {
+      const ce = parentComponent.ce!
+      const list = ce._teleportTargets
+      if (list) {
+        const idx = list.indexOf(target)
+        if (idx > -1) list.splice(idx, 1)
+      }
+    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b555f02 and 598e949.

📒 Files selected for processing (3)
  • packages/runtime-core/src/component.ts (1 hunks)
  • packages/runtime-core/src/components/Teleport.ts (1 hunks)
  • packages/runtime-dom/src/apiCustomElement.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/runtime-core/src/component.ts (2)
packages/runtime-core/src/renderer.ts (1)
  • RendererElement (155-155)
packages/runtime-core/src/index.ts (1)
  • RendererElement (307-307)
🔇 Additional comments (1)
packages/runtime-core/src/component.ts (1)

1276-1276: Interface switch to multi-target looks good; keep implementers’ element type broad

The change to _teleportTargets?: RendererElement[] is correct — no singular _teleportTarget occurrences found in packages. Keep implementers' element type broad (Element/RendererElement), not narrowed to HTMLElement.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (3)
packages/runtime-dom/__tests__/customElement.spec.ts (3)

1284-1303: Fix typos in test name and custom element tag.

Rename “tow” -> “two” for clarity and consistent naming.

-    test('render nested tow Teleports w/ shadowRoot false', async () => {
+    test('render nested two Teleports w/ shadowRoot false', async () => {
...
-      customElements.define('my-el-tow-teleport-child', Child)
+      customElements.define('my-el-two-teleport-child', Child)
...
-          return h('my-el-tow-teleport-child', null, {
+          return h('my-el-two-teleport-child', null, {

Also applies to: 1298-1298, 1303-1303


1313-1320: Strengthen assertions: prefer innerHTML and verify cleanup on unmount.

  • Assert target contents via innerHTML (consistent with neighboring tests).
  • Verify teleported nodes are removed after app.unmount().
-      expect(target1.outerHTML).toBe(
-        `<div><div slot="header">header</div></div>`,
-      )
-      expect(target2.outerHTML).toBe(
-        `<span><span slot="body">body</span></span>`,
-      )
-      app.unmount()
+      expect(target1.innerHTML).toBe(`<div slot="header">header</div>`)
+      expect(target2.innerHTML).toBe(`<span slot="body">body</span>`)
+      app.unmount()
+      await nextTick()
+      expect(target1.innerHTML).toBe('')
+      expect(target2.innerHTML).toBe('')

1291-1293: Add coverage: slots outside Teleports must still render (regression #13899).

This PR fixes slot collection across host + multiple teleport targets. Add a test mixing host-rendered slots with teleported ones to prevent regressions.

// Additional test (can be placed after the current two-teleports test)
test('render slots both outside and inside Teleports w/ shadowRoot false', async () => {
  const targetA = document.createElement('div')
  const targetB = document.createElement('div')

  const MixedChild = defineCustomElement(
    {
      render() {
        return [
          renderSlot(this.$slots, 'headerOutside'),
          h(Teleport, { to: targetA }, [renderSlot(this.$slots, 'headerInside')]),
          h(Teleport, { to: targetB }, [renderSlot(this.$slots, 'bodyInside')]),
          renderSlot(this.$slots, 'footerOutside'),
        ]
      },
    },
    { shadowRoot: false },
  )
  customElements.define('my-el-mixed-teleports-child', MixedChild)

  const App = {
    render() {
      return h('my-el-mixed-teleports-child', null, {
        default: () => [
          h('div', { slot: 'headerOutside' }, 'H-out'),
          h('div', { slot: 'headerInside' }, 'H-in'),
          h('span', { slot: 'bodyInside' }, 'B-in'),
          h('span', { slot: 'footerOutside' }, 'F-out'),
        ],
      })
    },
  }

  const app = createApp(App)
  app.mount(container)
  await nextTick()

  const ce = container.querySelector('my-el-mixed-teleports-child') as VueElement
  expect(ce.innerHTML).toBe(
    `<div slot="headerOutside">H-out</div><span slot="footerOutside">F-out</span>`,
  )
  expect(targetA.innerHTML).toBe(`<div slot="headerInside">H-in</div>`)
  expect(targetB.innerHTML).toBe(`<span slot="bodyInside">B-in</span>`)

  app.unmount()
  await nextTick()
  expect(targetA.innerHTML).toBe('')
  expect(targetB.innerHTML).toBe('')
})

Also applies to: 1303-1307

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 598e949 and 063d3b1.

📒 Files selected for processing (1)
  • packages/runtime-dom/__tests__/customElement.spec.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/runtime-dom/__tests__/customElement.spec.ts (4)
packages/runtime-dom/src/apiCustomElement.ts (1)
  • defineCustomElement (167-185)
packages/runtime-dom/src/index.ts (2)
  • defineCustomElement (255-255)
  • createApp (98-146)
packages/runtime-core/src/components/Teleport.ts (1)
  • Teleport (501-509)
packages/runtime-core/src/helpers/renderSlot.ts (1)
  • renderSlot (25-100)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Redirect rules
  • GitHub Check: Header rules
  • GitHub Check: Pages changed

@edison1105 edison1105 added 🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. scope: custom elements labels Sep 17, 2025
@edison1105
Copy link
Member

/ecosystem-ci run

@edison1105 edison1105 added the ready to merge The PR is ready to be merged. label Sep 18, 2025
@edison1105 edison1105 changed the title fix(custom-element): handle multiple Teleports in a custom element component fix(custom-element): properly mount multiple Teleports in custom element component w/ shadowRoot false Sep 18, 2025
@vue-bot
Copy link
Contributor

vue-bot commented Sep 18, 2025

📝 Ran ecosystem CI: Open

suite result latest scheduled
language-tools success success
pinia success success
radix-vue success success
quasar success success
nuxt success success
primevue success success
router success success
test-utils success success
vite-plugin-vue success success
vant success success
vue-macros success success
vuetify failure failure
vueuse success success
vitepress success success
vue-simple-compiler success success
vue-i18n success success

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/runtime-dom/src/apiCustomElement.ts (1)

666-678: Tighten types and avoid minor overhead in _getSlots

  • Use Element[] (not HTMLElement[]) to match the widened _teleportTargets type and support SVG targets.
  • Fast-path when there are no teleport targets; also switch to a simple for...of for clarity.
-  private _getSlots(): HTMLSlotElement[] {
-    const roots: HTMLElement[] = [this]
-    if (this._teleportTargets) {
-      roots.push(...this._teleportTargets)
-    }
-    return roots.reduce<HTMLSlotElement[]>((res, i) => {
-      res.push(...Array.from(i.querySelectorAll('slot')))
-      return res
-    }, [])
-  }
+  private _getSlots(): HTMLSlotElement[] {
+    const targets = this._teleportTargets
+    if (!targets || targets.size === 0) {
+      return Array.from(this.querySelectorAll('slot'))
+    }
+    const roots: Element[] = [this, ...targets]
+    const slots: HTMLSlotElement[] = []
+    for (const root of roots) {
+      slots.push(...root.querySelectorAll('slot'))
+    }
+    return slots
+  }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c7f80ee and 278aad8.

📒 Files selected for processing (3)
  • packages/runtime-core/src/component.ts (1 hunks)
  • packages/runtime-core/src/components/Teleport.ts (1 hunks)
  • packages/runtime-dom/src/apiCustomElement.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/runtime-core/src/component.ts
  • packages/runtime-core/src/components/Teleport.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: test / e2e-test
  • GitHub Check: Redirect rules
  • GitHub Check: Header rules
  • GitHub Check: Pages changed
🔇 Additional comments (1)
packages/runtime-dom/src/apiCustomElement.ts (1)

638-639: LGTM: collect slot outlets from host + all teleport targets

This addresses the regression by aggregating across multiple roots instead of a single target/host.

Please verify Teleport lifecycle maintains the set membership:

  • adds on mount/retarget
  • removes on unmount/disable
  • no stale targets after updates

I can run a quick script to scan Teleport.ts for add/remove of ce._teleportTargets if helpful.

@@ -224,7 +224,7 @@ export class VueElement
/**
* @internal
*/
_teleportTarget?: HTMLElement
_teleportTargets?: Set<HTMLElement>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

❓ Verification inconclusive

Fix type of _teleportTargets to align with interface and non-HTML targets

Using Set<HTMLElement> is narrower than ComponentCustomElementInterface’s Set<RendererElement> (runtime-dom alias is Element) and will cause an implements type incompatibility. It also excludes SVG targets.

Apply:

-  _teleportTargets?: Set<HTMLElement>
+  _teleportTargets?: Set<Element>

Optional (cleanup): consider clearing this set on disconnect to avoid stale refs:

       if (!this._connected) {
         if (this._ob) {
           this._ob.disconnect()
           this._ob = null
         }
         // unmount
         this._app && this._app.unmount()
         if (this._instance) this._instance.ce = undefined
         this._app = this._instance = null
+        this._teleportTargets?.clear()
+        this._teleportTargets = undefined
       }

If you’d like, I can run a repo-wide check to confirm the interface type is RendererElement and ensure no other declarations narrowed it.


Change _teleportTargets type to Set

Set is narrower than the ComponentCustomElementInterface's Set (runtime-dom alias Element) and excludes non-HTML targets (e.g. SVG). Apply:

-  _teleportTargets?: Set<HTMLElement>
+  _teleportTargets?: Set<Element>

Optional cleanup (clear on disconnect to avoid stale refs):

       if (!this._connected) {
         if (this._ob) {
           this._ob.disconnect()
           this._ob = null
         }
         // unmount
         this._app && this._app.unmount()
         if (this._instance) this._instance.ce = undefined
         this._app = this._instance = null
+        this._teleportTargets?.clear()
+        this._teleportTargets = undefined
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_teleportTargets?: Set<HTMLElement>
_teleportTargets?: Set<Element>
🤖 Prompt for AI Agents
In packages/runtime-dom/src/apiCustomElement.ts around line 227, the field
_teleportTargets is typed as Set<HTMLElement> which is too narrow compared to
ComponentCustomElementInterface's Set<RendererElement> (alias Element); change
its type to Set<Element> so non-HTML targets like SVG are allowed, and in the
custom element disconnect/cleanup path ensure you clear or null out
_teleportTargets (e.g., call clear() or set to undefined) to avoid retaining
stale references.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. ready to merge The PR is ready to be merged. scope: custom elements
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[custom-element] Slots outside Teleport not rendered when using shadowRoot: false (regression from #11851)
3 participants