Est.
Scott me up, Beamy!
Teleports are a cool feature of Vue. I had some teething problems getting everything right. Not that Teleports are difficult to use, but a) I use the Teleports with VitePress, i.e. in SSR and b) I want to teleport into a location inside my actual layout. Both requests require some extra caution.
TL;DR
If Vue teleport components fail to find their target or you get hydration errors for teleports in VitePress, you might want to try the following pattern:
<Teleport v-if:"target" :to="target">...</Teleport>
...
onMounted(() => (target.value = ...));
<Teleport v-if:"target" :to="target">...</Teleport>
...
onMounted(() => (target.value = ...));
In my experience, most errors result from evaluating the teleport targets too early. Therefore, a robust method for teleporting to another part of the same layout is to use Teleport with v-if
and a dynamic target in onMount
, as shown above. This is also the recommended way, see citation from the Vue Site below.
If you are interested in details, read on …
Step by Step
The teleport component lets you move a part of your template from its original location to a completely different part of your page. Logically, it is still part of your component and its hierarchy. However, a look at the DOM shows that the elements are inserted at the target position and not at the original position of the teleport.
But why would I need that? If I want to move things around on the page, why can't I just use CSS?
In fact, Vue teleports greatly simplify CSS positioning. If you don't want z-index
or position
properties to be constrained by the element's parent in the DOM, you can simply beam it to the body-element or any other suitable target.
Clear Instructions
Two important points are mentioned in the documentation of the teleport component:
[ About the teleport's target: ] The teleport
to
target must be already in the DOM when the teleport component is mounted. Ideally, this should be an element outside the entire Vue application[1].[ About handling teleports in SSR: ] If the rendered app contains Teleports, the teleported content will not be part of the rendered string. An easier solution is to conditionally render the Teleport on mount[2].
Even though I had read both warnings beforehand, I still screwed up.
Failed to Locate Teleport Target
My first attempt was
<Teleport to="#top-aside">...
<Teleport to="#top-aside">...
and it brought me the following warning:
⚠️
[Vue warn]: Failed to locate Teleport target with selector "#top-aside". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.
Unlike the examples in the documentation, my target element is not the body tag of the page. top-aside
is the id of an empty <aside>
, that is part of the general page layout. It is the one that shows the table of contents in the top right corner of the page. So much for Ideally, this should be an element outside the entire Vue application.
. My #top-aside
is part of the navigation bar and should be rendered long before the teleport component, which is on a content page below the navigation bar.
But when the teleport component asks its querySelector for #top-aside
it didn't find it. Apparently, the selector was evaluated before the element was actually inserted into the DOM.
To fix this, I replaced the static value of the to
-property with a dynamic value. This value initially points to the body element of the page and once my teleport component is mounted, the value switches to the desired #top-aside
.
<Teleport :to="target">...
...
const target = ref("body");
onMounted(() => (target.value = "#top-aside"));
<Teleport :to="target">...
...
const target = ref("body");
onMounted(() => (target.value = "#top-aside"));
While this got around the problem of the failed location of the targets, it is not a solution for SSR. It failed as soon as I ran vitepress build
to render the static server pages …
Hydration Completed but Contains Mismatches
The start page looked largely as expected at first, but the Teleport didn't seem to work. The JavaScript console stated:
🟥 Hydration completed but contains mismatches. app.e6623b26.js:1:31220
l http://localhost:5000/assets/app.e6623b26.js:1
mount http://localhost:5000/assets/app.e6623b26.js:1
...
🟥 Hydration completed but contains mismatches. app.e6623b26.js:1:31220
l http://localhost:5000/assets/app.e6623b26.js:1
mount http://localhost:5000/assets/app.e6623b26.js:1
...
When I clicked on a link, the entire layout was gone and only the teleported content was visible. The site was completely broken.
It turned out that I had overlooked the second sentence of the second hint above.
Therefore, I changed my pattern to
<template>
<Teleport v-if="target" :to="target">
<slot></slot>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
const target = ref("");
onMounted(() => (target.value = "#top-aside"));
</script>
<template>
<Teleport v-if="target" :to="target">
<slot></slot>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
const target = ref("");
onMounted(() => (target.value = "#top-aside"));
</script>
Guarded by the v-if
, the Teleport is rendered only after target
has been defined, i.e. after the template has been mounted. At that point, the selector can safely be evaluated. In contrast to the previous solution
, the first evaluation of the target selector occurs much later. Now the target is not only defined in the DOM, but also stable. Static rendering and hydration resemble the same model without deviations.
Energize!
Alternatively, it would be perfectly fine to start the transporter with some other truthy value and declare the destination statically.
<template>
<Teleport v-if="energize" to="#top-aside">
<slot></slot>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
const energize = ref(false);
onMounted(() => (energize.value = true));
</script>
<template>
<Teleport v-if="energize" to="#top-aside">
<slot></slot>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
const energize = ref(false);
onMounted(() => (energize.value = true));
</script>