Does Vue 3 Teleport only works to port outside vue?

15.6k Views Asked by At

Vue 3 has a new Teleport feature which replaces the portal-vue plugin in vue 2. However, I found that it is impossible to port a component to a place that is controlled by vue (=in the vue app). It only worked when porting outside (body, other elements...).

const app = {
  template: `
  <div>
    <h1>App</h1>
    <div id="dest"></div>
    <comp />
  </div>`
}


Vue.createApp(app).component('comp', {
  template: `
  <div>
    A component
    <Teleport to="#dest">
      Hello From Portal
    </Teleport>
  </div>`
}).mount('#app')
<script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>

<div id="app"></div>

As you can see, the console even reports, that a teleport target needs to be already rendered. But how can I tell vue which component to render first? In my example, the target is in the dom before the teleport.

This wasn't a problem in portal-vue and is quite a bummer because it makes the whole concept way less usable.

4

There are 4 best solutions below

0
On

Or one can create MountedTeleport component to extend @Daniel solution so that the model isn't poluted

<template>
    <Teleport :to="to" v-if="isMounted"><slot></slot></Teleport>
</template>

<script>
export default {
    name: "MountedTeleport",
    props: ['to'],
    data() {
        return {isMounted: false}
    },
    mounted() {
        this.isMounted = true;
    }
}
</script>

and it's used as

<MountedTeleport to="#given-selector">your html....</MountedTeleport>

if still the teleport initilizes first one may use

mounted() {
    this.$nextTick(() => {
        this.isMounted = true;
    })
}

or even if that renders first then probaly setTimeout will do the job

2
On

The Vue error message kind of points to the problem. It says Invalid Teleport target on mount: null

The problem is that the target does not exist YET.

This can be easily fixed by only rendering the teleport portion only after the component is mounted.

It seems like this is something that Vue should handle without the explicit check. When you pass the id as a string, it's hard to tell whether the target is a Vue component or not, especially if hasn't rendered yet. But I'm only speculating on the team's intention here.

const app = {
  template: `
  <div>
    <h1>App</h1>
    <div id="dest"></div>
    <comp />
  </div>`
}

Vue.createApp(app).component('comp', {
  template: `
  <div>
    A component
    <Teleport to="#dest" v-if="isMounted">
      Hello From Portal
    </Teleport>
  </div>`,
  data: function(){
    return { 
        isMounted: false
    }
  },
  mounted(){
    this.isMounted = true
  }
}).mount('#app')
<script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>

<div id="app"></div>

0
On

In this issue on GH contains problem solve. it must be like this

   //Root template in App.vue(it doesn't need to wrap in a div or smth else)
<template>
    <div class="modal" :style="style">
        <div class="modal-background"></div>
        <div class="modal-content">
            <div id="modal"></div>
        </div>
        <button class="modal-close is-large" aria-label="close" 
        @click="modal.hideModal"></button>
    </div>

    // App instance
    <section class="section">
            <div class="container">
                <Navbar/>
                <router-view/>
            </div>
        </section>
</template>
3
On

Template refs are references to real DOM elements, so until DOM is actually rendered, template refs will simply be null.

Vue adopts a virtual dom architecture. Your code typically only instructs vue to change the virtual dom, and the process of syncing these changes to the real dom is called patching. Patching happens asynchronously. Vue takes care of populating/repopulating the this.$refs whenever necessary (after every time it patches the dom), and the earliest time for your $refs to become accessible is usually the mounted hook (it could be later if you have conditional rendering).

However, it is not to say that $refs will never change afterwards. Vue component mounting sequence can also be quite surprising sometimes as well (e.g. is child component or parent component going to be mounted first?). Usually all these wouldn't be any issue as in a reactive world these sequences should not matter. However, the moment you start doing imperative stuff - e.g. relying on lifecycle hooks to set a flag that later drives the conditional rendering of your template etc. - you become vulnerable again to the issues reactive frameworks attempt to solve in the first place.

So for a more bullet-proof approach, you would better stay reactive and watch the $refs changes and react to it. The problem though is that $refs is not reactive.

But Vue allows the ref directive to take a function so that you can control where you would like to store the template ref. You can therefore store the ref in something reactive by nature - i.e. data. Feed the data into the child component as a prop, and bind your teleport target to that prop. Now everything is reactive again.

Just to demonstrate, I've modified your example to have two extra buttons controlling whether to render the teleport target div. Now you no longer have access to the dest div template ref in the mounted hook because until you click the teleport on button the div will not be rendered:

const app = {
  template: `
    <div>
      <h1>App</h1>
      <button @click="show=true"> teleport on </button>
      <button @click="show=false"> teleport off </button>
      <div v-if="show" :ref="(el) => dest = el"></div>
      <comp :target="dest"/>
    </div>`,

  data() {
    return { dest: null, show: false };
  }
}


Vue.createApp(app).component('comp', {
  props: ["target"],

  template: `
    <div>
      A component
      <Teleport v-if="target !== null" :to="target">
        Hello From Portal
      </Teleport>
    </div>`
}).mount('#app')
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

<div id="app"></div>