Vuetify Tabs as Nested dynamic routes erroring on parent prop

Viewed 1599

Trying to make a Vuetify Tab component with nested routes for the tab panels, based off a dynamic route.

Starting HTML of

<div id="app">
  <v-app>
    <v-main>
      <v-container>
        <router-link to="/parent/123">Go To Parent</router-link>
        <router-view></router-view>
      </v-container>
    </v-main>
  </v-app>
</div>

with components of

const Parent = {
template: '<div><h1>Hello from parent <em>{{obj.name}}</em></h1>' +
    '<v-tabs>' +
        '<v-tab to="child1">child1</v-tab>' +
        '<v-tab to="child2">child2</v-tab>' +
        '<v-tab to="child3">child3</v-tab>' +   
    '</v-tabs>' +    
    '<router-view></router-view>' +
    '</div>',
    props: { obj : {type: Object, required: true}}
};

const Child1 = {
  template: '<h3>Child1</h3>'
};
const Child2 = {
  template: '<h3>Child2</h3>'
};
const Child3 = {
  template: '<h3>Child3</h3>'
};

and a router configuration of

const routes = [{
    path: '/parent/:id',
    component: Parent,
    props: true,    
    beforeEnter: function (routeTo, routeFrom, next) {
        routeTo.params.obj = mockObj;
      next();
    },
    children:[
    {    
      path: 'child1',      
      component: Child1    
    },
    {
      path: 'child2',
      component: Child2
    },
    {
      path: 'child3',
      component: Child3
     }    
    ]    
  }

does all sort of work, to the extent that clicking on each tab does navigate to child route correctly (as in if I remove the prop of [obj] all works, although I couldn't get the fiddle to reproduce this, but I can locally. The fiddle is not passing through the dynamic route param in the child routes and I don't know why) but as soon as I put the parent obj prop in they all error with vue.runtime.esm.js:639 [Vue warn]: Missing required prop: "obj" found in Parent when navigating to the children.

The obj is just used as a placeholder example, in the actual implementation obj is pulled from an API in the route guard beforeEnter and passed into the props. In this example its just var mockObj = {name: "Steve"}, so the parent component says 'Hello from parent steve'

I'm fairly new to Vue.js and Vuetify and just don't understand whats going on here, but it appears when navigating to the child routes its running the parent!?

Fiddle here

2 Answers

First because v-tab is using standard router-link, everything below applies to any app using Vue router and we can ignore Vuetify

All my observations and suggestions are demonstrated in the fiddle I created (based on yours)

Vue router and relative links

Relative links are sort of broken (you can find multiple issues like this). If you hover over the tabs in your example, you will see URL ending like this #/parent/child2 with the :id dynamic section completely omitted.

You can sort of fix it by changing your "Go To Parent" link from <router-link to="parent/123">Go To Parent</router-link> to <router-link to="parent/123/">Go To Parent</router-link> (notice the trailing slash) but that is not very reliable (user can type URL into browser bar a you app is suddenly broken)

Way to fix it properly is either by using absolute paths (<v-tab :to="{ path: '/parent/${$route.params.id}/child1' }">child1</v-tab>) (used also in official example of Nested routes feature) or give a name to your routes and use link like this <v-tab :to="{ name:'child1' }">child1</v-tab>

Nested routes vs components

Even it may seem unnecessary in this case, when navigating to child route, router will process whole route including parent. Because parent component doesn't change, router will reuse the existing Parent component instance (and not trigger beforeEnter guard) but it will still re-render the component and refresh its props. You can check this by creating beforeRouteUpdate hook on your Parent component (docs). And because beforeEnter guard is not executed, there is nothing to pass into the Parent obj prop.

You can sort of fix it by reassigning the param inside the beforeRouteUpdate hook of Parent component:

beforeRouteUpdate(to, from, next) {
    // HackyHack
    if(from.params.obj)
      to.params.obj = from.params.obj
      
    next()
  }

...but in my eyes its more hack than a real solution (and you should probably also check id param is same value)

Vue router and object params

Even tho it is technically possible to pass objects with Router (and therefore use it to share data between components), I do not recommend it. I'v already answered multiple questions here on SO of people being bitten when trying to pass objects as route params. In general params are good for things which can be fully encoded into URL iself (numbers, string etc). Passing "hidden" params (not just objects) lead to a problems with browser navigation (back button) or users copy pasting urls.

When considering solutions to share a state between components of your app, I recommend taking a look at Vuex - Vue official solution to this problem...

How to change your app to use Vuex

  1. Remove the beforeEnter guard and move the "fetch" logic into Parent component - see Router documentation Fetching After Navigation. Store result from API in Vuex. On every route change (change of :id) check the store - if the data for current :id are there, no need to fetch otherwise clear the store and data for new :id must be fetched
  2. Change your Child components to take data not from props but directly from Vuex

For nested router-view, you need to enter the parent props again to route the child component along with parent.So you have to use routes.beforeEach hook instead of beforeEnter. As routes.beforeEach is called for rendering every child component. But beforeEnter is called only once when the router object initiated for parent component. Here is the solution.

var mockObj = {
  name: "Steve"
};

const Parent = {
  template: "<div><h1>Hello from parent <em>{{obj.name}}</em></h1>" +
    "<v-tabs>" +
    `<v-tab :to="'/parent/'+id">child1</v-tab>` +
    `<v-tab :to="'/parent/'+id+'/child2'">child2</v-tab>` +
    `<v-tab :to="'/parent/'+id+'/child3'">child3</v-tab>` +
    "</v-tabs>" +
    "<router-view></router-view>" +
    "</div>",
  props: {
    obj: {
      type: Object,
      required: true
    },
    id: {
      required: true
    }
  }
};

const Child1 = {
  template: "<h3>Child1</h3>"
};
const Child2 = {
  template: "<h3>Child2</h3>"
};
const Child3 = {
  template: "<h3>Child3</h3>"
};

const routes = [{
  path: "/parent/:id",
  component: Parent,
  props: true,
  children: [{
      path: "",
      component: Child1
    },
    {
      path: "child2",
      component: Child2
    },
    {
      path: "child3",
      component: Child3
    }
  ]
}];

const router = new VueRouter({
  routes
});
router.beforeEach((to, from, next) => {
  to.params.obj = mockObj;
  next();
});

new Vue({
  el: "#app",
  router,
  vuetify: new Vuetify()
});
<head>
  <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet" />
  <link href="https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet" />
  <link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet" />
  <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
  <script src="https://unpkg.com/vue-router@3.4.3/dist/vue-router.js"></script>
</head>
<div id="app">
  <v-app>
    <v-main>
      <v-container>
        <router-link to="/parent/123">Go To Parent</router-link>
        <router-view></router-view>
      </v-container>
    </v-main>
  </v-app>
</div>

Related