DEV Community

loading...

Building a Router component for Glimmer.js

Rajasegar Chandran
Javascript Toolsmith, Front-end developer
・9 min read

This is the first in the series of posts explaining how to implement a routing system for Glimmer.js apps. In this post we are going to create a Router component for Glimmer. Since Glimmer is only a rendering engine, it doesn't have the full-fledged routing capabilities like Ember.js. Lately I have been playing with the glimmer-experimental libraries for my pet projects, I needed a routing system for those apps. This is my attempt at creating one.

So I have searched for similar implementations with Glimmer and I found out this realword-example-app by Dan Freeman, where he conditionally render the components based on the active route manually. I wanted a more refined and automated approach which can be scaled to a lot of routes or pages.

I have been heavily inspired by the Routing libraries used by other JS Frameworks like React, Svelte, etc. I wanted to build one for Glimmer. So if you look at how react-router is implemented, you layout your components and their respective paths inside the Router component.

import React from "react";
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";

export default function App() {
  return (
    <Router>
      <div>
        <nav>
          <ul>
            <li>
              <Link to="/">Home</Link>
            </li>
            <li>
              <Link to="/about">About</Link>
            </li>
          </ul>
        </nav>
        {/* A <Switch> looks through its children <Route>s and
            renders the first one that matches the current URL. */}
        <Switch>
          <Route path="/about">
            <About />
          </Route>
          <Route path="/">
            <Home />
          </Route>
        </Switch>
      </div>
    </Router>
  );
}

Enter fullscreen mode Exit fullscreen mode

In the same manner, Svelte has the svelte-routing library which is kind of similar.

<!-- App.svelte -->
<script>
  import { Router, Link, Route } from "svelte-routing";
  import Home from "./routes/Home.svelte";
  import About from "./routes/About.svelte";
  import Blog from "./routes/Blog.svelte";

  export let url = "";
</script>

<Router url="{url}">
  <nav>
    <Link to="/">Home</Link>
    <Link to="about">About</Link>
    <Link to="blog">Blog</Link>
  </nav>
  <div>
    <Route path="blog/:id" component="{BlogPost}" />
    <Route path="blog" component="{Blog}" />
    <Route path="about" component="{About}" />
    <Route path="/"><Home /></Route>
  </div>
</Router>
Enter fullscreen mode Exit fullscreen mode

So for our Glimmer apps, the routing logic is going to be something like this:

App.js

import Component, { hbs, tracked } from "@glimmerx/component";
import { Router, Route, Link } from "./GlimmerRouter.js";

import Home from './pages/Home.js';
import About from './pages/About.js';
import Contact from './pages/Contact.js';

import "./App.css";

export default class App extends Component {
  Home = Home;
  About = About;
  Contact = Contact;

  static template = hbs`
    <nav>
      <ul>
        <li><Link @to="/">Home</Link></li>
        <li><Link @to="/about">About</Link></li>
        <li><Link @to="/contact">Contact</Link></li>
      </ul>
    </nav>
    <main>
      <Router>
        <Route @path="/" @component={{this.Home}}/>
        <Route @path="/about" @component={{this.About}}/>
        <Route @path="/contact" @component={{this.Contact}}/>
      </Router>
    </main>
    `;
}
Enter fullscreen mode Exit fullscreen mode

We are going to see how we can build each of those components, Router, Route, and Link using Glimmer components.

Router Registry

The first thing we need for the routing system to work is a Router registry, which is a collection of route urls and the route object. Every routing system needs a mapping for the url and the component to be mounted when the user visits that url. So we need a registry for the same which stores these mappings. It could be as simple as an array of objects like this:

[
  { path: '/', component: Home },
  { path: '/about', component: About },
]
Enter fullscreen mode Exit fullscreen mode

Route component

The Route component is responsible for mapping a particular path or url to the corresponding Glimmer component. It basically tells the router that when the user is visiting the url it needs to mount the component. It is also responsible for adding a route to the registry.

export class Route extends Component {
  constructor() {
    super(...arguments);
    const route = {
      path: this.args.path,
      component: this.args.component,
    };
    window.routerRegistry.push(route);
  }

  static template = hbs`{{yield}}`;
}
Enter fullscreen mode Exit fullscreen mode

Link component

The next component is a custom Link component to basically render the links with anchor elements and a custom class called glimmer-link which we will use to bind click events to trap the onclick events on the anchor elements to implement client-side navigation.

export class Link extends Component {
  static template = hbs`
  <a href={{@to}} class="glimmer-link">{{yield}}</a>
  `;
}

Enter fullscreen mode Exit fullscreen mode

Router Component

This is the meat of our Routing system where all the actions take place. The Router component is responsible for handling the routing events, by listening to the click events of our anchor elements generated by our Link components and the popstate events of the window object whenever there a change in the browser url happens.

It uses the Router registry which we saw earlier and find the matching route objects, in our case, Glimmer components and mount them accordingly in the outlet DOM nodes which is signified by the DOM element with the id glimmer-router-outlet.

Router initialization

In the Router component's constructor we need setup the router registry and event listeners for anchor element click events and window.popstate events. And we will start the router to listen for these as soon the DOMContentLoaded event fires,it will start navigating to the respective pages.

  constructor() {
    super(...arguments);
    window.routerRegistry = [];
    document.addEventListener("click", this.route.bind(this));
    window.addEventListener("popstate", this.handlePopState.bind(this));
    document.addEventListener("DOMContentLoaded", this.start.bind(this));
  }

Enter fullscreen mode Exit fullscreen mode

The start function will just redirect to the / url which is the home page route.

  start(ev) {
    const path = location.pathname || "/";
    this.navigate(path);
  }
Enter fullscreen mode Exit fullscreen mode

Rendering the components

Next, we need a function called renderPage where we will render our components on to the outlet component which is nothing but a div with an id glimmer-router-outlet.

  renderPage(component) {
    const outlet = document.getElementById("glimmer-router-outlet");
    outlet.innerHTML = "";
    renderComponent(component, outlet);
  }
Enter fullscreen mode Exit fullscreen mode

Routing to pages on anchor clicks

Next we will take a look at our route function which is the click event handler for our anchor elements. We are listening to click events in the window object and filter them out for the anchor tags with the class name glimmer-link and then route the page. One thing to note here is to prevent the default behavior of the anchor clicks so that you are not redirected to a different page. We will find the route objects from the registry using the href value from the respective anchor tags and match it against the path property of the route objects in the registry. We are also pushing the url to the history state so that our history navigation
with the back and forward buttons work properly.

  route(ev) {
    if (ev.target.classList.contains("glimmer-link")) {
      ev.preventDefault();
      const url = new URL(ev.target.href);
      const [route] = this.registry.filter((r) => r.path === url.pathname);
      if (route) {
        history.pushState({}, "", url);
        this.renderPage(route.component);
      }
    }
  }

Enter fullscreen mode Exit fullscreen mode

Handling window.popstate

Next we will add a function to handle the window.popstate events and route to the corresponding pages and
mounting the components accordingly.

  handlePopState(event) {
    const [route] = this.registry.filter((r) => r.path === location.pathname);
    if (route) {
      this.renderPage(route.component);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Programmatic Navigation

We also need our Router object to have capabilities like navigating programmatically to different routes based on the
application state. This will be useful for Authentication, login and other similar stuff where we need to redirect our
users from Login page to Home page or something like that. This is done in the navigate function where in we will pass a path parameter like /about and using that we find the respective route object and invoke the renderPage function.

  navigate(path) {
    const [route] = this.registry.filter((r) => r.path === path);
    if (route) {
      this.renderPage(route.component);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Tearing down event listeners on component destroy

The last thing we need to do is to cleanup our Router object in the willDestroy lifecycle hook by removing all the event listeners we have attached previously in the constructor function.

  willDestroy() {
    document.removeEventListener("click", this.route);
    window.removeEventListener("popstate", this.handlePopState);
  }
Enter fullscreen mode Exit fullscreen mode

This is how our final Router component will look like.

export class Router extends Component {
  @tracked registry = window.routerRegistry;

  constructor() {
    super(...arguments);
    window.routerRegistry = [];
    document.addEventListener("click", this.route.bind(this));
    window.addEventListener("popstate", this.handlePopState.bind(this));
    document.addEventListener("DOMContentLoaded", this.start.bind(this));
  }

  renderPage(component) {
    const outlet = document.getElementById("glimmer-router-outlet");
    outlet.innerHTML = "";
    renderComponent(component, outlet);
  }

  route(ev) {
    if (ev.target.classList.contains("glimmer-link")) {
      ev.preventDefault();
      const url = new URL(ev.target.href);
      const [route] = this.registry.filter((r) => r.path === url.pathname);
      if (route) {
        history.pushState({}, "", url);
        this.renderPage(route.component);
      }
    }
  }

  handlePopState(event) {
    const [route] = this.registry.filter((r) => r.path === location.pathname);
    if (route) {
      this.renderPage(route.component);
    }
  }

  willDestroy() {
    document.removeEventListener("click", this.route);
    window.removeEventListener("popstate", this.handlePopState);
  }

  navigate(path) {
    const [route] = this.registry.filter((r) => r.path === path);
    if (route) {
      this.renderPage(route.component);
    }
  }

  start(ev) {
    const path = location.pathname || "/";
    this.navigate(path);
  }

  static template = hbs`
      {{yield}}
      <div id="glimmer-router-outlet"></div>
   `;
}

Enter fullscreen mode Exit fullscreen mode

Lazy loading components

One thing we need to notice here is that, we are eagerly loading all the component JS in the App component while
setting up the router. This will lead to performance issues when our component size becomes bigger and we have large number of components.

Before code splitting, this is how our network request timeline look like in the Dev Tools Network panel

Alt Text

Other frameworks like React have come up with something like code-splitting JS bundles and lazy loading components. We can do something like this in our Glimmer apps with dynamic imports in the Router's renderPage function.

After code splitting, as you can see we will only load the respective components in the page instead of loading all the components code at one shot.

Alt Text

renderPage(component) {
    const outlet = document.getElementById("glimmer-router-outlet");
    outlet.innerHTML = "<h1>Loading page...</h1>";

    import(`./pages/${component}.js`).then(component => {
      outlet.innerHTML = "";
      renderComponent(component.default, outlet);
    });

  }
Enter fullscreen mode Exit fullscreen mode

First we display the loading indicator, before fetching the component through dynamic imports, then once the promise is resolved, we will get the component instance and use the default import which is the component class definition and pass it to the renderComponent function.

For this to work, we need to change the prop type of the Route component by giving the component name instead of
the component instance itself in App.js.

<Router>
  <Route @path="/" @component="Home"/>
  <Route @path="/about" @component="About"/>
  <Route @path="/contact" @component="Contact"/>
</Router>
Enter fullscreen mode Exit fullscreen mode

Please keep in mind that for code-splitting and lazy-loading to work properly we need to make some changes to our build configuration also. I am using Snowpack as my bundler and it automatically bundle the component source files separately. You can read more about bundling Glimmer apps with Snowpack in my previous blog post here.

If you are using Webpack you need to set up multiple entry-points in you webpack.config.js.

So this is how our full and final Router implementation will look like.

GlimmerRouter.js

import Component, { hbs, tracked } from "@glimmerx/component";
import { renderComponent } from "@glimmerx/core";

export class Link extends Component {
  static template = hbs`
  <a href={{@to}} class="glimmer-link">{{yield}}</a>
  `;
}

export class Route extends Component {
  constructor() {
    super(...arguments);
    const route = {
      path: this.args.path,
      component: this.args.component,
    };
    window.routerRegistry.push(route);
  }

  static template = hbs`{{yield}}`;
}

export class Router extends Component {
  @tracked registry = window.routerRegistry;

  constructor() {
    super(...arguments);
    window.routerRegistry = [];
    document.addEventListener("click", this.route.bind(this));
    window.addEventListener("popstate", this.handlePopState.bind(this));
    document.addEventListener("DOMContentLoaded", this.start.bind(this));
  }

  renderPage(component) {
    const outlet = document.getElementById("glimmer-router-outlet");
    outlet.innerHTML = "<h1>Loading page...</h1>";

    import(`./pages/${component}.js`).then(component => {
      outlet.innerHTML = "";
      renderComponent(component.default, outlet);
    });

  }

  route(ev) {
    if (ev.target.classList.contains("glimmer-link")) {
      ev.preventDefault();
      const url = new URL(ev.target.href);
      const [route] = this.registry.filter((r) => r.path === url.pathname);
      if (route) {
        history.pushState({}, "", url);
        this.renderPage(route.component);
      }
    }
  }

  handlePopState(event) {
    const [route] = this.registry.filter((r) => r.path === location.pathname);
    if (route) {
      this.renderPage(route.component);
    }
  }

  willDestroy() {
    document.removeEventListener("click", this.route);
    window.removeEventListener("popstate", this.handlePopState);
  }

  navigate(path) {
    const [route] = this.registry.filter((r) => r.path === path);
    if (route) {
      this.renderPage(route.component);
    }
  }

  start(ev) {
    const path = location.pathname || "/";
    this.navigate(path);
  }

  static template = hbs`
      {{yield}}
      <div id="glimmer-router-outlet"></div>
   `;
}

Enter fullscreen mode Exit fullscreen mode

References

Demo

The demo for this post is hosted here and the source code for the same is available in this repository.

In the next post, we will replace all the boilerplate logic of our Router component with a more standard and established routing library which will take care of all the necessary things we missed in the Router like query parameters, dynamic segments, etc.,

Please let me know about any queries or feedback in the comments section. I will be happy to discuss more about the things in the post with you all.

Discussion (0)