-
-
Notifications
You must be signed in to change notification settings - Fork 8.9k
fix(custom-element): properly mount multiple Teleports in custom element component w/ shadowRoot false #13900
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
base: main
Are you sure you want to change the base?
Conversation
WalkthroughUpdate custom element teleport handling to track multiple teleport targets (as a Changes
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (5 passed)
✨ Finishing touches
🧪 Generate unit tests
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.
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 |
Size ReportBundles
Usages
|
@vue/compiler-core
@vue/compiler-dom
@vue/compiler-sfc
@vue/compiler-ssr
@vue/reactivity
@vue/runtime-core
@vue/runtime-dom
@vue/server-renderer
@vue/shared
vue
@vue/compat
commit: |
There was a problem hiding this 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 crashesIf 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) continuepackages/runtime-core/src/components/Teleport.ts (1)
265-285
: Missing: update CE targets on Teleport retargetingWhen
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 rootsAlign 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 unmountStale 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
📒 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 broadThe change to _teleportTargets?: RendererElement[] is correct — no singular _teleportTarget occurrences found in packages. Keep implementers' element type broad (Element/RendererElement), not narrowed to HTMLElement.
There was a problem hiding this 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
📒 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
/ecosystem-ci run |
📝 Ran ecosystem CI: Open
|
There was a problem hiding this 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[]
(notHTMLElement[]
) 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
📒 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 targetsThis 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> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ 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.
_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.
close #13899
Additionally, when two Teleports are used inside
ce-component
, only one of them renders correctly.issue playground
this pr playground
Summary by CodeRabbit
New Features
Tests