RouteInfo Metadata (3.10)

Learn how to associate runtime data with your route hierarchy by building a breadcrumbs UI from scratch.

Summary


This feature lets us associate runtime data with our route hierarchy.

Let's build a dynamic breadcrumbs UI to learn how it works. We'll learn about some really neat Router APIs along the way.


We're working with a small GitHub browser app. Let's start with the root of our app, where we'll be adding our breadcrumbs, and let's imagine our ideal API.

It might look something like this:

<nav>
  {{#each this.breadcrumbs as |breadcrumb|}}
    {{#if breadcrumb.isCurrentPage}}
      <span>Ember.js</span>
    {{else}}
      <a class='text-blue-500'>Home</a>
      <span class='px-1'>></span.>
    {{/if}}
  {{/each}}
</nav>

If we can generate this array of breadcrumbs dynamically, and keep it up to date as the user moves throughout our application, this template snippet should work for our UI.

Ok, so we know we need a breadcrumbs computed property on our github controller. It depends on the currentRoute, which is a property on our router service. So we'll inject the router service and define the property:

import Controller from "@ember/controller";
import { inject } from "@ember/service";
import { computed } from "@ember/object";

export default Controller.extend({
  router: inject(),

  breadcrumbs: computed("router.currentRoute", function() {
    console.log(this.router.currentRoute);
  })
});

currentRoute returns us a RouteInfo object, which has lots of useful information about the current route. The feature we're learning about, RouteInfo Metadata, lets us add a metadata property to this object. We'll learn more about that soon.

For now, we want this computed property to return an array of the current route hierarchy, starting with the github route as the root. We can write a recursive function to achieve this, taking advantage of the route.parent property to traverse the active hierarchy.

import Controller from "@ember/controller";
import { inject } from "@ember/service";
import { computed } from "@ember/object";

function parents(route) {
  return route.name === "github" ? [route] : [...parents(route.parent), route];
}

export default Controller.extend({
  router: inject(),

  breadcrumbs: computed("router.currentRoute", function() {
    let activeRoutes = parents(this.router.currentRoute);

    return activeRoutes;
  })
});

This gives us an array of RouteInfo objects that updates as we navigate throughout the app.

Next, we want to filter out any index pages, because we don't want them to show up in our breadcrumbs UI. We'll also use map to give us a spot to transform our RouteInfo objects into the specific data we need for our breadcrumbs.

breadcrumbs: computed("router.currentRoute", function() {
  let activeRoutes = parents(this.router.currentRoute);

  return activeRoutes
    .filter(route => route.localName !== "index")
    .map(route => ({
      label: route.localName
    }));
})

We're now using breadcrumbs.label in our UI, and things are working so far. Next, we need to define breadcrumb.isCurrentPage.

Here's how that works:

breadcrumbs: computed("router.currentRoute", function() {
  let activeRoutes = parents(this.router.currentRoute);

  return activeRoutes
    .filter(route => route.localName !== "index")
    .map(route => ({
      label: route.localName,
      isCurrentPage:
        this.router.currentRoute.name === route.name ||
        route.child.localName === "index"
    }));
})

If the currentRoute.name matches this route's name, it's the current page. But there's one more condition, and that's where an index page is currently being rendered. In that case, we actually want the parent route to be the active breadcrumb. So we'll check if the route.child.localName is index as well.

Now our UI is correctly rendering each breadcrumb in one of two states. We can add a route property so the links actually work:

breadcrumbs: computed("router.currentRoute", function() {
  let activeRoutes = parents(this.router.currentRoute);

  return activeRoutes
    .filter(route => route.localName !== "index")
    .map(route => ({
      label: route.localName,
      route: route.name,
      isCurrentPage:
        this.router.currentRoute.name === route.name ||
        route.child.localName === "index"
    }));
})

And now we can use all these properties in our template:

<nav>
  {{#each this.breadcrumbs as |breadcrumb|}}
    {{#if breadcrumb.isCurrentPage}}
      <span>{{breadcrumb.label}}</span>
    {{else}}
      <LinkTo @route={{breadcrumb.route}} class='text-blue-500'>
        {{breadcrumb.label}}
      </LinkTo>
      <span class='px-1'>></span>
    {{/if}}
  {{/each}}
</nav>

OK, we've built our basic breadcrumbs UI using only the existing Router APIs. So where does this new RouteInfo Metadata feature come in?

Well, our breadcrumbs are displaying only static labels. We're using route.localName, which is priting things like org and repo, but for a real UI like this we'd ideally like to be able to use some of the runtime data we're fetching from the server.

This is where the new buildRouteInfoMetadata hook comes in.

We can use this hook on our routes to associate new data with our RouteInfo objects. Let's start with our github route:

// routes/github.js
import Route from "@ember/routing/route";

export default Route.extend({
  buildRouteInfoMetadata() {
    return {
      {
        breadcrumb: "Home"
      }
    };
  }
});

Anything we return from buildRouteInfoMetadata will be merged with the metadata property on this route's RouteInfo object. And if we put a debugger in our breadcrumbs computed property, we'll see the new data there!

That means we can update our transforms to look for this property:

.map(route => ({
  label:
    route.metadata && route.metadata.breadcrumb
      ? route.metadata.breadcrumb
      : route.localName,
  route: route.name,
  isCurrentPage:
    this.router.currentRoute.name === route.name ||
    route.child.localName === "index"

If we have a breadcrumb property on our metadata, we'll use that as our label. And now our UI shows "Home" instead of the static github name that comes from our router definition.

The last part to tackle is our dynamic runtime routes. The org route has a model associated with it, and in this case we want our breadcrumb UI to actually show the name of the resolved model.

To do this, we need to update our breadcrumb metadata property to be a function that takes in a model:

// routes/org.js
import Route from "@ember/routing/route";

export default Route.extend({
  buildRouteInfoMetadata() {
    return {
      breadcrumb(model) {
        return model.name;
      }
    };
  },

  model(params) {
    return fetch(`/orgs/${params.org}`).then(res => res.json());
  }
});

This way, the breadcrumb property can be dynamic based off of the resolved data from the model() hook.

The last step is to update our computed property to actually invoke this function passing in the model. The way we get a model is by accessing the RouteInfo's attributes property:

.map(route => ({
  label:
    route.metadata && route.metadata.breadcrumb
      ? route.metadata.breadcrumb(route.attributes)
      : route.localName,

And just like that, our breadcrumbs can now depend on the resolved model from the route!

This API is a great example of the power of Ember's architecture and router guarantees. It's carefully designed to let us use the dynamic data from the router's blocking model hook, so we can know what to render before having to actually render our app for the first time. This means it works great for both client-side navigation as well as SSR via FastBoot.

By leaning on Ember's routing conventions you now have a common place to augment your route hierarchy with runtime data specific to your application. Check out the RFC for more use cases for this great new feature!

Questions?

us, or ask in #media on Discord