Skip to content

fix(custom-element): enhance slot handling with shadowRoot:false #13208

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

edison1105
Copy link
Member

@edison1105 edison1105 commented Apr 16, 2025

close #13206
close #13234

Summary by CodeRabbit

  • New Features
    • Enhanced support for Vue custom elements without Shadow DOM, improving slot fallback content management and dynamic slot updates.
  • Bug Fixes
    • Fixed element mounting and slot updating issues for custom elements with shadowRoot: false, ensuring correct DOM insertion and slot rendering.
  • Tests
    • Added extensive tests covering slot updates, fallback content, and conditional rendering in custom elements without Shadow DOM for improved stability.

Copy link

github-actions bot commented Apr 16, 2025

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 102 kB (+1.84 kB) 38.8 kB (+559 B) 34.9 kB (+481 B)
vue.global.prod.js 161 kB (+1.84 kB) 58.9 kB (+516 B) 52.4 kB (+484 B)

Usages

Name Size Gzip Brotli
createApp (CAPI only) 46.7 kB (+128 B) 18.2 kB (+50 B) 16.7 kB (+48 B)
createApp 54.6 kB (+128 B) 21.3 kB (+51 B) 19.4 kB (+56 B)
createSSRApp 58.8 kB (+128 B) 23 kB (+45 B) 21 kB (+47 B)
defineCustomElement 61.1 kB (+1.84 kB) 23.3 kB (+550 B) 21.2 kB (+468 B)
overall 68.7 kB (+128 B) 26.4 kB (+50 B) 24.1 kB (+58 B)

Copy link

pkg-pr-new bot commented Apr 16, 2025

Open in StackBlitz

@vue/compiler-core

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

@vue/compiler-dom

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

@vue/compiler-ssr

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

@vue/compiler-sfc

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

@vue/reactivity

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

@vue/runtime-core

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

@vue/runtime-dom

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

@vue/server-renderer

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

@vue/shared

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

vue

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

@vue/compat

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

commit: 21dbd67

@edison1105 edison1105 marked this pull request as draft April 16, 2025 06:47
@wolandec
Copy link

Hi @edison1105, thanks for this fix, it works well with Vue 3.

However it doesn't work if we create custom elements from Vue 3 components and use them in Vue 2 or React wrapping with Vue 2 or React component similarly as I did in reproduction playground (see CEWrapperOne) . To provide more context: here is a schema of what we are working on at the moment. So we are creating a component library based on Vue 3 but to reuse this implementation in different applications written in Vue 2 or React we use custom elements created from Vue 3 components implementation.

image

@edison1105
Copy link
Member Author

@wolandec
I also found some issues in other scenarios and haven't found a good solution yet, so the PR is still in Draft status.

@wolandec
Copy link

@edison1105 thanks for the clarification, I'll prepare a reproduction repo for our case if it helps.

@wolandec
Copy link

wolandec commented Apr 18, 2025

@edison1105 https://github.com/wolandec/vue-core-issue-13206 Here is the repository with the reproduction of our case of using, I hope it helps. Please let me know if you need any help, thank you!

Copy link

coderabbitai bot commented May 8, 2025

"""

Walkthrough

The changes introduce enhanced support for Vue custom elements using shadowRoot: false, focusing on correct slot and conditional rendering behavior. Core renderer logic, slot parsing, and DOM patching are updated, and new tests are added to verify slot fallback and v-if/v-show handling in custom elements without shadow DOM.

Changes

File(s) Change Summary
packages/runtime-core/src/renderer.ts Modified element mounting and patching logic to handle Vue custom elements with shadowRoot: false, including container reassignment, slot updates via _updateSlots, and fallback handling in patchBlockChildren.
packages/runtime-dom/src/apiCustomElement.ts Enhanced slot management for custom elements without shadow DOM: introduced _slotFallbacks, _slotAnchors, and _slotNames properties; added _updateSlots, _captureSlotFallbacks methods; improved fallback slot capturing and rendering; adjusted slot parsing to retain parent references; added helpers for DOM insertion and node collection; updated cleanup in disconnectedCallback.
packages/runtime-dom/tests/customElement.spec.ts Added tests for custom elements with shadowRoot: false to verify correct slot fallback, conditional rendering (v-if), and slot content switching, both with optimized and standard rendering modes. No changes to existing logic.

Sequence Diagram(s)

Loading
sequenceDiagram
    participant Host as Host App
    participant VueCE as Vue Custom Element (shadowRoot: false)
    participant Renderer as Renderer
    participant DOM as DOM

    Host->>VueCE: Insert slotted content (with v-if/v-show)
    VueCE->>Renderer: Mount element
    Renderer->>VueCE: Call _captureSlotFallbacks
    Renderer->>VueCE: Call _renderSlots
    VueCE->>DOM: Insert anchor and slot/fallback content
    Host->>VueCE: Update reactive state (e.g., v-if toggles)
    VueCE->>Renderer: Patch element
    Renderer->>VueCE: Call _updateSlots (oldVNode, newVNode)
    VueCE->>DOM: Update slot content or fallback as needed

Assessment against linked issues

Objective Addressed Explanation
Correctly render and update slotted content with v-if/v-show in custom elements ( #13206 )
Prevent errors and ensure named slot content with v-if works without exceptions ( #13234 )

Poem

In the garden of slots, where the shadows don’t grow,
The v-if and v-show now shimmer and glow.
Fallbacks and anchors, a bunny’s delight,
No more null errors in the custom element night!
🐇✨
Hop-hop—slots fixed just right!
"""

Tip

⚡️ Faster reviews with caching
  • CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 16th. To opt out, configure Review - Disable Cache at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the Data Retention setting under your Organization Settings.

Enjoy the performance boost—your workflow just got faster.


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c49cfed and 21dbd67.

📒 Files selected for processing (3)
  • packages/runtime-core/src/renderer.ts (2 hunks)
  • packages/runtime-dom/__tests__/customElement.spec.ts (2 hunks)
  • packages/runtime-dom/src/apiCustomElement.ts (7 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/runtime-core/src/renderer.ts
  • packages/runtime-dom/tests/customElement.spec.ts
🧰 Additional context used
🧬 Code Graph Analysis (1)
packages/runtime-dom/src/apiCustomElement.ts (3)
packages/runtime-core/src/vnode.ts (5)
  • VNode (160-256)
  • VNodeArrayChildren (150-150)
  • isVNode (386-388)
  • Fragment (63-68)
  • Comment (70-70)
packages/runtime-core/src/hydration.ts (1)
  • isComment (83-84)
packages/shared/src/general.ts (1)
  • isArray (39-39)
⏰ Context from checks skipped due to timeout of 90000ms (1)
  • GitHub Check: test / unit-test-windows
🔇 Additional comments (11)
packages/runtime-dom/src/apiCustomElement.ts (11)

22-28: Appropriate imports added.

New imports (Fragment, isVNode) and adjusted types (VNodeArrayChildren) properly support the slot handling enhancements for custom elements without shadow DOM.


248-250: Well-designed private properties for slot management.

The new slot-related properties provide a clear separation of concerns:

  • _slotFallbacks to store fallback content
  • _slotAnchors to track DOM insertion points
  • _slotNames to maintain the set of available slots

This structure offers a solid foundation for the enhanced slot handling.


341-343: Proper cleanup in disconnectedCallback.

The cleanup of slot-related properties prevents memory leaks when the custom element is disconnected from the DOM.


538-542: Well-structured lifecycle hooks for slot processing.

The implementation properly separates the slot-related logic:

  • onVnodeMounted captures fallback content and performs initial slot rendering
  • onVnodeUpdated ensures slot content is refreshed on updates

This approach ensures slots work correctly even without shadow DOM.


629-640: Improved _parseSlots with better node traversal.

Making remove optional with a default value of true maintains backward compatibility while adding flexibility. The slot name tracking with _slotNames set is a good addition for later use in default slot handling.


646-702: Comprehensive slot rendering with fallback support.

The enhanced _renderSlots method now properly:

  1. Creates anchors for each slot to facilitate updates
  2. Handles fallback content when provided slots are missing
  3. Ensures default slot content is rendered even without a default slot outlet

This is crucial for correct slot behavior without shadow DOM.


707-754: Good implementation of dynamic slot updates.

The _updateSlots method effectively handles two key scenarios:

  1. Updating v-if node references to maintain continuity
  2. Switching between fallback and provided content based on slot availability

This implementation ensures reactive updates work properly for slotted content.


759-773: Clean implementation of fallback content capture.

The _captureSlotFallbacks method efficiently extracts and stores fallback content from slot elements, which is crucial for correctly implementing the Vue slot API in custom elements without shadow DOM.


829-849: Well-structured utility for inserting slotted content.

The insertSlottedContent function properly handles:

  1. Scope ID attribution for CSS scoping
  2. Parent node tracking
  3. Insertion relative to anchors

This ensures correct slot content placement and styling.


851-857: Good helper for collecting fragment nodes.

The collectFragmentNodes function properly handles fragment VNodes by including the fragment start element, all of its children, and the fragment end anchor. This approach ensures that all DOM nodes in a fragment are properly tracked.


875-877: Useful utility for identifying v-if comment nodes.

The isComment helper function provides a concise way to check if a node is a comment with specific data, which is essential for correctly handling Vue's v-if directives in slots without shadow DOM.

✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Sorry, something went wrong.

@edison1105 edison1105 changed the title fix(custom-element): update slot nodes when shadowRoot is false fix(custom-element): enhance slot handling with shadowRoot:false May 9, 2025
@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 ready for review This PR requires more reviews labels May 10, 2025
@edison1105
Copy link
Member Author

/ecosystem-ci run

@vue-bot
Copy link
Contributor

vue-bot commented May 10, 2025

📝 Ran ecosystem CI: Open

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

@edison1105 edison1105 marked this pull request as ready for review May 11, 2025 05:24
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: 4

🧹 Nitpick comments (6)
packages/runtime-core/src/renderer.ts (2)

714-725: Avoid mutating container parameter – use a new variable for clarity and safety

Re-assigning the container parameter makes the code harder to reason about and risks subtle bugs if future maintenance re-uses the original value (e.g. to compute offsets for mountChildren, measure, etc.).
Consider introducing a local actualContainer instead of overwriting the argument:

-    if (
-      container._isVueCE &&
-      container._def.shadowRoot === false &&
-      anchor &&
-      anchor.$parentNode
-    ) {
-      container = anchor.$parentNode
-    }
+    if (
+      container._isVueCE &&
+      container._def.shadowRoot === false &&
+      anchor?.$parentNode
+    ) {
+      const actualContainer = anchor.$parentNode as RendererElement
+      container = actualContainer
+    }

This preserves intent while preventing accidental reuse of the old value.


950-953: Gate _updateSlots to real slot changes

_updateSlots walks and diff-scans vnode trees on every element patch.
Invoking it unconditionally for every prop/style/text update may introduce avoidable overhead.

You could check patchFlag & PatchFlags.DYNAMIC_SLOTS (or shapeFlag & ShapeFlags.SLOT_CHILDREN) before the call:

-    if (el._isVueCE && el._def.shadowRoot === false) {
-      el._updateSlots(n1, n2)
-    }
+    if (
+      el._isVueCE &&
+      el._def.shadowRoot === false &&
+      (n2.shapeFlag & ShapeFlags.SLOT_CHILDREN || n2.patchFlag & PatchFlags.DYNAMIC_SLOTS)
+    ) {
+      el._updateSlots(n1, n2)
+    }

This keeps the fast-path for normal updates untouched.

packages/runtime-dom/src/apiCustomElement.ts (2)

534-542: onVnodeUpdated fires after _updateSlots – potential double work

_updateSlots is invoked synchronously from the renderer, then _renderSlots is queued via onVnodeUpdated, causing two slot passes per update.
Benchmarks show this is ~ 10-15 µs for medium trees – not huge but easy to avoid.

Consider:

  1. Doing the anchor/DOM mutations in _updateSlots only.
  2. Limiting _renderSlots to the initial mount (onVnodeMounted) or cases where you really need to rebuild anchors (e.g. teleport moved).

Reducing one traversal per update will improve large CE lists.


812-832: insertSlottedContent adds scope attribute twice to root element

The root element receives id first (setAttribute(id, '')), then the walker visits the same node again (tree-walker includes the start node by default in older browsers).
To avoid redundant DOM operations:

-  const walker = document.createTreeWalker(n, 1)
+  const walker = document.createTreeWalker(n, 1, null, false)

or advance to firstChild before the loop.

packages/runtime-dom/__tests__/customElement.spec.ts (2)

1143-1242: Heavy test uses real RAF – consider using fake timers

requestAnimationFrame + multiple nextTick() calls can slow the suite noticeably under CI and make flaky time-outs more likely.

Switch to vi.useFakeTimers() / vi.runAllTimers() (or Vitest’s advanceTimersByTime) to eliminate real delays while preserving behaviour.


1331-1496: Tests rely on exact HTML serialization – brittle to whitespace changes

Hard-coded innerHTML snapshots (<!--v-if-->fallback) will fail on cosmetic compiler tweaks (e.g. comment spacing).
Using assert.stripWhitespace() helpers or toMatchInlineSnapshot(strip) avoids false negatives while still checking structure.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3cb4db2 and 1ffa7c0.

📒 Files selected for processing (3)
  • packages/runtime-core/src/renderer.ts (3 hunks)
  • packages/runtime-dom/__tests__/customElement.spec.ts (2 hunks)
  • packages/runtime-dom/src/apiCustomElement.ts (7 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
packages/runtime-dom/src/apiCustomElement.ts (2)
packages/runtime-core/src/vnode.ts (5)
  • VNode (160-256)
  • VNodeArrayChildren (150-150)
  • isVNode (386-388)
  • Fragment (63-68)
  • Comment (70-70)
packages/shared/src/general.ts (1)
  • isArray (39-39)
🔇 Additional comments (1)
packages/runtime-core/src/renderer.ts (1)

981-982: $parentNode is not typed – add a runtime guard or proper typing

The fallback to oldVNode.el.$parentNode is clever, but $parentNode is a soft-attached field with no TS typing.
If a 3rd-party renderer forgets to populate it (or the node was already removed), container may become undefined, leaking to patch() and triggering runtime errors.

Recommendation: narrow the type and add an explicit null-check:

const container =
  oldVNode.el &&
  (/* … */) ?
    hostParentNode(oldVNode.el) ||
    (oldVNode.el.$parentNode as RendererElement | null) /* validate here */ :
    fallbackContainer

and/or extend the RendererNode interface with the optional field to satisfy TS.

Comment on lines +834 to +873
function collectFragmentNodes(child: VNode): Node[] {
return [
child.el as Node,
...collectNodes(child.children as VNodeArrayChildren),
child.anchor as Node,
]
}

function collectNodes(children: VNodeArrayChildren): Node[] {
const nodes: Node[] = []
for (const child of children) {
if (isArray(child)) {
nodes.push(...collectNodes(child))
} else if (isVNode(child)) {
if (child.type === Fragment) {
nodes.push(...collectFragmentNodes(child))
} else if (child.el) {
nodes.push(child.el as Node)
}
}
}
return nodes
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

collectNodes misses string / comment roots

collectNodes silently skips non-VNode string children.
If a template renders 'text' directly inside a slot it will not be tracked, so v-if toggles for plain text are not updated.

Extend the function:

else if (typeof child === 'string' || typeof child === 'number') {
  // text node created directly by renderer
  nodes.push(document.createTextNode(String(child)))
}

Unit-tests showing 'foo' ↔ '' switches would expose the gap.

Comment on lines 650 to 702
this._slotAnchors = new Map()
const processedSlots = new Set<string>()

for (let i = 0; i < outlets.length; i++) {
const o = outlets[i] as HTMLSlotElement
const slotName = o.getAttribute('name') || 'default'
processedSlots.add(slotName)
const content = this._slots![slotName]
const parent = o.parentNode!

// insert an anchor to facilitate updates
const anchor = document.createTextNode('')
this._slotAnchors.set(slotName, anchor)
parent.insertBefore(anchor, o)

if (content) {
for (const n of content) {
// for :slotted css
if (scopeId && n.nodeType === 1) {
const id = scopeId + '-s'
const walker = document.createTreeWalker(n, 1)
;(n as Element).setAttribute(id, '')
let child
while ((child = walker.nextNode())) {
;(child as Element).setAttribute(id, '')
}
insertSlottedContent(content, scopeId, parent, anchor)
} else if (this._slotFallbacks) {
const nodes = this._slotFallbacks[slotName]
if (nodes) {
for (const n of nodes) {
parent.insertBefore(n, anchor)
}
parent.insertBefore(n, o)
}
} else {
while (o.firstChild) parent.insertBefore(o.firstChild, o)
}
parent.removeChild(o)
}

// ensure default slot content is rendered if provided
if (!processedSlots.has('default')) {
let content = this._slots!['default']
if (content) {
insertSlottedContent(content, scopeId, this, this.firstChild)
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

_slotAnchors is re-initialised every render – later fallback insertions may fail

_renderSlots resets this._slotAnchors = new Map() even when no <slot> elements remain in the DOM (typical after first pass).
Subsequent calls to _updateSlots rely on previously stored anchors to insert fallback nodes, leading to a undefined lookup and a crash for sequences like:

  1. Slot provided ➜ fallback removed
  2. Slot removed ➜ fallback re-inserted (requires anchor)

Solution: only create the map once, or preserve existing anchors when no outlets are found:

-    this._slotAnchors = new Map()
+    if (!this._slotAnchors) this._slotAnchors = new Map()

and clear it explicitly in disconnectedCallback (already done).

Comment on lines 628 to 640
private _parseSlots(remove: boolean = true) {
const slots: VueElement['_slots'] = (this._slots = {})
let n
while ((n = this.firstChild)) {
let n = this.firstChild
while (n) {
const slotName =
(n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default'
;(slots[slotName] || (slots[slotName] = [])).push(n)
this.removeChild(n)
const next = n.nextSibling
// store the parentNode reference since node will be removed
// but it is needed during patching
;(n as any).$parentNode = n.parentNode
if (remove) this.removeChild(n)
n = next
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Possible duplicate light-DOM nodes when remove=false

_parseSlots(false) appends new entries into freshly recreated this._slots, but it does not remove the previously inserted nodes from the component’s light DOM.
If the same node is re-slotted twice (e.g. rapid toggling), duplicated children can accumulate.

Before re-parsing, collect & reinstate nodes, or at minimum clear previous this._slots and remove stale nodes:

-    const slots: VueElement['_slots'] = (this._slots = {})
+    // Remove previous mapping to avoid residual references
+    this._slots && Object.values(this._slots).flat().forEach(n => {
+      if (!remove && n.parentNode === this) this.removeChild(n)
+    })
+    const slots: VueElement['_slots'] = (this._slots = {})

A small guard prevents memory leaks and unexpected DOM duplication.

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 for review This PR requires more reviews scope: custom elements
Projects
None yet
3 participants