| |

Vue.js 92 🟢 Interoperability between Virtual DOM Components and Vapor Components

⏱️ Estimated read: 11 min | |

Vapor Mode and Virtual DOM Mode are two distinct rendering engines that can coexist in a single Vue application through the vaporInteropPlugin. The plugin allows Vapor components to be rendered inside VDOM trees and VDOM components to be rendered inside Vapor trees, covering standard props, events, and slots. The interoperability is functional but not seamless: it introduces a runtime boundary that has performance costs, behavioral edge cases, and a set of known limitations that the Vue team is still addressing in the 3.6 release candidate phase.

Key point: The interop boundary is a crossing between two rendering runtimes. Every VDOM component instantiated from a Vapor template is a separate crossing, and the overhead scales with the number of crossings, not with the complexity of the components. The Vue team recommends placing the boundary above repeated regions, not inside them.


Why interoperability is necessary

The incremental adoption problem. Vapor Mode does not support the full Vue API surface. Options API, app.config.globalProperties, getCurrentInstance(), and per-element lifecycle events are not available in Vapor components . A codebase that wants to adopt Vapor for performance-sensitive pages cannot convert everything at once. The interop plugin allows a mixed application where some routes or components use Vapor and the rest remain VDOM.

The library ecosystem problem. Component libraries built on VDOM—Vuetify, PrimeVue, Element Plus—cannot be recompiled to Vapor. A Vapor application that needs a date picker or a data table must render the VDOM component through the interop boundary. The plugin is what makes this possible, but the Vue team notes that “there will most likely still be rough edges when using a VDOM-based component library in Vapor Mode” .

The bundle trade-off. A pure Vapor application created with createVaporApp excludes the VDOM runtime, which is where the bundle size reduction comes from. Installing vaporInteropPlugin pulls the VDOM runtime back in, offsetting most of the size benefit . The interop is a compatibility feature, not a size optimization.


a. Enabling Interop and the Boundary Rule

The plugin is installed on the application instance. For a VDOM application that wants to render Vapor components, the entry point is createApp().use(vaporInteropPlugin). For a Vapor application that wants to render VDOM components, the entry point is createVaporApp().use(vaporInteropPlugin) .

import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)
  .mount('#app')

The boundary rule is the most consequential guidance for performance. Every classic component instantiated directly from a Vapor-compiled template is its own interop crossing. A compound component like Checkbox.Root and Checkbox.Indicator written inline in a Vapor v-for creates two crossings per iteration. A list of 200 checkboxes is approximately 400 crossings .

The fix is to place the boundary above the repeated region. A single VDOM wrapper component that renders the entire list is one crossing, regardless of how many items the list contains .

<!-- ❌ BAD: every Root and Indicator in the loop is its own crossing -->
<div v-for="item in items">
  <VdomCheckbox :item="item" />
</div>

<!-- ✅ GOOD: one crossing for the entire list -->
<VdomList :items="items" />

b. Known Limitations and Edge Cases

The interop covers standard props, events, and slots, but the release notes and issue tracker document several specific limitations that are not resolved in the 3.6 release candidate.

slots.default() inside a VDOM component. A Vapor slot cannot be rendered with slots.default() inside a VDOM component. The renderSlot helper must be used instead . The reason is that in Vapor, calling slots.default() is not a side-effect-free inspection. It executes the slot’s rendering logic, creates Blocks and DOM nodes, and registers reactive effects . A VDOM component that treats the slot as a function to be invoked will fail or produce unexpected behavior.

Vapor <Transition> with slotted VDOM content. A Vapor <Transition> wrapping a <slot /> does not apply enter/leave transitions to VDOM content passed through the slot from a VDOM parent. The content is mounted and unmounted instantly with no transition classes applied. Direct VDOM children and Vapor children through slots both animate correctly; the failure is specific to VDOM content arriving through the slot boundary .

useTemplateRef() crash in Vapor components rendered from VDOM. A Vapor component using useTemplateRef() and rendered from a VDOM parent through the interop plugin can throw TypeError: Cannot define property X, object is not extensible in development mode. The issue was reported against 3.6.0-rc.1 but could not be reproduced in isolation by the maintainer, suggesting it depends on a specific nesting hierarchy .

getCurrentInstance() returns null. This is a feature compatibility limitation, not an interop bug, but it affects libraries that use the function to detect component context. Vuetify0’s solution is to read Vue 3.6’s internal currentInstance export when available and fall back to getCurrentInstance() on older versions .

Behavior inconsistencies in edge cases. Vapor Mode attempts to match VDOM behavior, but the two renderers are fundamentally different. Minor inconsistencies in edge cases are expected and are not considered breaking changes unless the behavior was previously documented .


c. The Recommended Adoption Strategy

The Vue team’s guidance is consistent across the release notes and the interop documentation: keep Vapor and VDOM components in distinct regions of the application, and avoid mixed nesting as much as possible . The interop is a bridge for incremental adoption and library compatibility, not a target architecture.

The recommended use cases for Vapor in 3.6 are partial usage in existing applications for performance-sensitive sub-pages, and building small new applications entirely in Vapor . Migrating an existing component tree to Vapor is not recommended during the release candidate phase.

For applications that must mix the two modes, the practical rules are: place the interop boundary above repeated regions, use renderSlot instead of slots.default() when rendering Vapor slots from VDOM components, and verify that any VDOM component library used inside a Vapor region behaves correctly with the interop plugin installed.


Complete Example Session

// ============================================
// PART 1: VDOM APP WITH VAPOR CHILD
// ============================================
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)
  .mount('#app')


// ============================================
// PART 2: VAPOR APP WITH VDOM CHILD
// ============================================
import { createVaporApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createVaporApp(App)
  .use(vaporInteropPlugin)
  .mount('#app')
// Note: this pulls in the VDOM runtime


// ============================================
// PART 3: BOUNDARY PLACEMENT
// ============================================
// ❌ BAD: VDOM component inside Vapor v-for
// <div v-for="item in items">
//   <VdomCheckbox :item="item" />
// </div>

// ✅ GOOD: one VDOM wrapper around the list
// <VdomList :items="items" />


// ============================================
// PART 4: SLOT RENDERING IN VDOM COMPONENT
// ============================================
// ❌ BAD: slots.default() in a VDOM component
// const content = slots.default?.()

// ✅ GOOD: use renderSlot
import { renderSlot } from 'vue'
// renderSlot(slots, 'default')


// ============================================
// PART 5: KNOWN LIMITATIONS
// ============================================
// Vapor slot cannot be rendered with slots.default() in VDOM
// Vapor <Transition> does not animate VDOM content through slot
// useTemplateRef() may crash in deep VDOM nesting
// getCurrentInstance() returns null in Vapor

The five parts trace the two application modes, the boundary rule, and the two most actionable limitations.


Quick Reference

Interop Entry Points

ScenarioEntry Point
VDOM app hosting VaporcreateApp(App).use(vaporInteropPlugin)
Vapor app hosting VDOMcreateVaporApp(App).use(vaporInteropPlugin)
Pure VaporcreateVaporApp(App)

Boundary Rule

PlacementCrossingsPerformance
VDOM component inside Vapor v-forN per iterationPoor
VDOM wrapper above the loop1Good

Known Limitations (3.6 RC)

LimitationDetail
slots.default() in VDOMMust use renderSlot instead
Vapor <Transition> with slotted VDOMNo enter/leave animation
useTemplateRef() in deep VDOM nestingMay throw in dev mode
getCurrentInstance()Returns null in Vapor
Behavior inconsistenciesMinor edge-case differences expected

Best Practices

✅ Do This:

// Place the interop boundary above repeated regions
<VdomList :items="items" />                                            // ✅
// Use renderSlot in VDOM components that render Vapor slots
renderSlot(slots, 'default')                                          // ✅
// Keep Vapor and VDOM in distinct regions
// Use Vapor for performance-sensitive sub-pages                          // ✅

❌ Don’t Do This:

// Nest VDOM components inside a Vapor v-for
<VdomComp v-for="i in items" />  // N crossings                       // ❌
// Call slots.default() to inspect a Vapor slot
slots.default?.()  // executes the slot                              // ❌
// Use a VDOM component library in Vapor without testing
// Expect rough edges in edge cases                                       // ❌

Common Pitfalls

PitfallWhy It HappensFix
Interop overhead in loopsVDOM component inside Vapor v-forMove boundary above loop
Slot content fails to renderslots.default() used in VDOMUse renderSlot
Transition does not animateVDOM content through Vapor slotKnown limitation; use direct VDOM child
useTemplateRef crashesDeep VDOM nestingUse plain ref() instead
Library component misbehavesVDOM library in VaporTest with interop plugin; expect edges

Real-World Examples

1. VDOM App with Vapor Child

createApp(App).use(vaporInteropPlugin).mount('#app')

2. Vapor App with VDOM Child

createVaporApp(App).use(vaporInteropPlugin).mount('#app')

3. Boundary Above Loop

<VdomList :items="items" />

4. renderSlot in VDOM

renderSlot(slots, 'default')

5. getCurrentInstance Caveat

// Returns null inside a Vapor component
getCurrentInstance()

6. Vuetify0 Instance Detection

// Read currentInstance export, fall back to getCurrentInstance

7. Transition Limitation

<!-- Vapor <Transition> with VDOM slot content does not animate -->

8. useTemplateRef Workaround

const inputRef = ref(null) // instead of useTemplateRef

9. Distinct Regions

<!-- Vapor region -->
<VaporPage />
<!-- VDOM region -->
<VdomDashboard />

10. Bundle Trade-Off

// Pure Vapor excludes VDOM runtime
createVaporApp(App)
// Interop pulls VDOM runtime back in

Visual

The Interop Boundary

┌──────────────────────────────────────────────────────────────┐
│  VAPOR APP                                                   │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Vapor component                                         ││
│  │                                                         ││
│  │  ┌───────────────────────────────────────────────────┐ ││
│  │  │ VDOM wrapper (one crossing)                       │ ││
│  │  │  ┌─────────────────────────────────────────────┐  │ ││
│  │  │  │ VDOM list with 200 items                    │  │ ││
│  │  │  │ (no per-item crossing)                      │  │ ││
│  │  │  └─────────────────────────────────────────────┘  │ ││
│  │  └───────────────────────────────────────────────────┘ ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  The boundary is above the repeated region.                  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Known Edge Cases

┌──────────────────────────────────────────────────────────────┐
│  slots.default() in VDOM     → use renderSlot instead        │
│                                                              │
│  Vapor <Transition>          → VDOM slot content does not    │
│  with VDOM slot content        animate                       │
│                                                              │
│  useTemplateRef()            → may crash in deep VDOM        │
│  in deep nesting               nesting                       │
│                                                              │
│  getCurrentInstance()        → returns null in Vapor         │
│                                                              │
│  These are known limitations, not bugs.                      │
│  The Vue team is addressing them in future releases.         │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Bundle Trade-Off

┌──────────────────────────────────────────────────────────────┐
│  PURE VAPOR APP              MIXED APP (INTEROP)             │
│                                                              │
│  ┌─────────────────┐          ┌─────────────────┐           │
│  │ Vapor runtime   │          │ Vapor runtime   │           │
│  │                 │          │ VDOM runtime    │           │
│  └────────┬────────┘          │ Interop plugin  │           │
│           ▼                    └────────┬────────┘           │
│  ┌─────────────────┐                   ▼                     │
│  │ ~7.9 kB         │          ┌─────────────────┐           │
│  │ (Hello World)   │          │ ~22.8 kB        │           │
│  └─────────────────┘          └─────────────────┘           │
│                                                              │
│  Interop negates the bundle-size benefit.                    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Interop pluginvaporInteropPlugin
VDOM app hosting VaporcreateApp().use(vaporInteropPlugin)
Vapor app hosting VDOMcreateVaporApp().use(vaporInteropPlugin)
Boundary ruleAbove repeated regions, not inside
Slot renderingrenderSlot not slots.default()
Known Transition issueVDOM slot content does not animate
useTemplateRef issueMay crash in deep nesting
Recommended usageDistinct regions, partial adoption

Key takeaways:

  • The interop plugin bridges the two runtimes, but the bridge has a cost. Vapor and VDOM components can be nested through vaporInteropPlugin, but the VDOM runtime is pulled back into the bundle, negating the size benefit of Vapor. The plugin is for compatibility and incremental adoption, not for size optimization.
  • The boundary should be placed above repeated regions. Every VDOM component instantiated from a Vapor template is a separate crossing. A list of 200 VDOM components inside a Vapor loop is 200 crossings. A single VDOM wrapper around the list is one crossing. The performance difference is proportional to the crossing count.
  • slots.default() is not safe in VDOM components that receive Vapor slots. It executes the slot’s rendering logic instead of inspecting it. The renderSlot helper must be used instead. This is a documented limitation in the 3.6 release notes.
  • Several edge cases remain unresolved in the 3.6 RC phase. Vapor <Transition> does not animate VDOM content passed through a slot. useTemplateRef() may crash in deep VDOM nesting. getCurrentInstance() returns null in Vapor components. These are known limitations, not regressions, and the Vue team is addressing them in future releases.
  • The recommended strategy is distinct regions and partial adoption. Use Vapor for performance-sensitive sub-pages in existing applications, or build small new applications entirely in Vapor. Avoid mixed nesting where possible. The interop is a migration tool, not an architectural goal.

Remember: Interoperability between Vapor and VDOM is a compatibility feature, not a performance feature. The two rendering engines have fundamentally different architectures, and the bridge between them is functional but not free. Every crossing is a runtime cost, every edge case is a potential bug, and every mixed nesting is a boundary that must be tested. The Vue team’s guidance is consistent: keep the regions distinct, place the boundary above repeated structures, and use the interop plugin only where the alternative—rewriting a VDOM component in Vapor—is not feasible. The goal is not to mix the two modes; the goal is to adopt Vapor incrementally without breaking the parts that still depend on the VDOM.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!