-
-
Notifications
You must be signed in to change notification settings - Fork 9k
Description
Vue version
3.5.18
Link to minimal reproduction
Steps to reproduce
- Create a component with
<Teleport to="body"> - Add a reactive ref:
const highlightedId = ref(null) - Create a computed property that depends on this ref
- Display the computed value in the teleported template
- Add a button that changes the ref value
- Click the button and observe:
- ✅ Console shows ref changed
- ✅ Computed property runs
- ❌ Template does NOT update
What is expected?
When a reactive ref changes inside a component using <Teleport>:
- ✅ Computed properties should recalculate (WORKS)
- ✅ Watchers should trigger (WORKS)
- ✅ Template should re-render with new data (EXPECTED)
What is actually happening?
- ✅ Reactive refs update correctly
- ✅ Computed properties run and return new values
- ✅ Watchers fire with correct new/old values
- ❌ Template NEVER re-renders - DOM stays frozen with old data
This creates a complete disconnect where JavaScript reactivity works perfectly but the visual layer is completely broken.
Minimal Reproduction
Component Code
<template>
<Teleport to="body">
<div class="modal">
<!-- This text NEVER updates when highlightedId changes -->
<div v-if="highlightedItem">
Selected: {{ highlightedItem.name }}
</div>
<!-- This style NEVER changes -->
<div :style="{ background: highlightedId ? 'red' : 'blue' }">
Status indicator
</div>
<!-- Button to change the ref -->
<button @click="selectItem('item-123')">
Select Item
</button>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const items = ref([
{ id: 'item-123', name: 'Item 123' },
{ id: 'item-456', name: 'Item 456' }
])
const highlightedId = ref(null)
// ✅ This computed DOES run when highlightedId changes
const highlightedItem = computed(() => {
console.log('Computing for ID:', highlightedId.value) // LOGS CORRECTLY
return items.value.find(i => i.id === highlightedId.value)
})
// ✅ This watcher DOES fire when highlightedId changes
watch(highlightedId, (newId) => {
console.log('ID changed to:', newId) // LOGS CORRECTLY
})
// This function DOES change the ref value
const selectItem = (id) => {
highlightedId.value = id
console.log('Set ID to:', id) // LOGS CORRECTLY
// But template shows OLD value! ❌
}
</script>Console Output (Everything Works)
Set ID to: item-123
Computing for ID: item-123
ID changed to: item-123
DOM Output (Nothing Updates)
<!-- Still shows nothing or old value -->
<div class="modal">
<!-- This stays empty even though highlightedItem is now defined -->
<!-- This stays blue even though highlightedId is now truthy -->
<div style="background: blue">
Status indicator
</div>
</div>System Info
OS: Linux
Node: v20.19.0
Package Manager: npm
Build Tool: Vite 7.0.6
Vue: 3.5.18
Component Type: Modal with <Teleport to="body">Any additional comments?
Debugging attempts performed (none of these worked):
1. Using nextTick()
const selectItem = async (id) => {
highlightedId.value = id
await nextTick()
// Template still frozen ❌
}2. Using v-show instead of v-if
<div v-show="highlightedItem"> <!-- Still frozen ❌ -->3. Adding :key to child elements
<div :key="highlightedId"> <!-- Still frozen ❌ -->4. Using reactive() instead of ref()
const state = reactive({ highlightedId: null }) // Still frozen ❌5. Force update methods
instance.proxy.$forceUpdate() // No effect ❌The ONLY Working Workaround:
Adding a "render key" to the root element inside <Teleport> and incrementing it on every change:
<template>
<Teleport to="body">
<!-- KEY on root element forces full re-render -->
<div class="modal" :key="`modal-${renderKey}`">
<div v-if="highlightedItem">
Selected: {{ highlightedItem.name }} <!-- NOW UPDATES ✅ -->
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue'
const highlightedId = ref(null)
const renderKey = ref(0) // Force re-render counter
const highlightedItem = computed(() => {
return items.value.find(i => i.id === highlightedId.value)
})
const selectItem = (id) => {
highlightedId.value = id
renderKey.value++ // 🔑 Force entire component to re-createWhy this "works" (but is a terrible workaround):
Changing the :key forces Vue to destroy and recreate the entire DOM subtree, bypassing the Teleport reactivity bug. However, this is:
- ❌ Expensive (full component remount)
- ❌ Loses component state
- ❌ Runs all lifecycle hooks again
- ❌ Not a proper solution, just a workaround
Any additional comments?
This bug is extremely confusing for developers because:
- No errors or warnings - Vue is completely silent
- Reactivity appears to work - console logs show correct values
- Looks like user error - easy to assume you did something wrong
- Hard to diagnose - takes extensive debugging to realize it's a framework issue
Our debugging experience:
- Time to diagnose: ~2 hours
- Iterations attempted: ~20 different approaches
- Where we got stuck: Computed properties and watchers working perfectly made us think our code was correct
- What revealed it: Comparing console.log output to DOM inspector showed complete mismatch
Root cause hypothesis:
The <Teleport> component appears to disconnect teleported DOM from Vue's reactivity tracking:
- Teleport moves content to
<body>(or other target) - Vue's reactivity system continues tracking dependencies (refs, computeds work)
- But the render/patch mechanism loses the connection to the teleported subtree
- When dependencies update, the render function doesn't get called for teleported content
- Only full component recreation (via
:keychange) forces a render
Expected behavior:
<Teleport> should maintain full reactivity regardless of where content is moved in the DOM. Developers should not need workarounds like the renderKey pattern.
Our real-world use case:
We encountered this in a modal component showing financial trading data:
- Modal uses
<Teleport to="body">for proper z-index stacking - Users click table rows to highlight chart positions
- Chart should update to show highlighted trade
- Without workaround: chart stays completely frozen despite all data updating