Team Tito Engineering

Two routers are better than one: third-party permalinks with Tito Widget V2

A few weeks ago I merged a PR on our js-tito app, the Tito widget, called "Proxy router", and after deploying the change, I ended years of frustration with what might be one of the best things we’ve collectively shipped.Tito Widget V2 has been years in the making (sometimes it feels like even the tiniest things around here are years in the making, but that's definitely a digression).

The proxy router implementation, borrowed from Vito, allowed us to achieve my ultimate goal for the Tito widget: permalinks on third party sites.

The first iteration actually did this. When you load the Tito widget into your site, as such:

<script src='https://js.tito.io/v2' async></script> quite a lot of cool stuff goes on. If you view source on that script, you'll see that it's a very lightweight script that, well ... loads more JS. Quite a bit more. I'm a little bit ashamed of that, because V1 was relatively light on the JS (but heavy in just about every other department).

Embedding a Vue app

What it’s loading is interesting though: it’s a Vue.js app. An entire app. When you place <tito-widget ...> in your code, Vue Custom Element actually goes and mounts the entire Widget V2 app including Vue Router. I love Vue router. I love the way it maps a route to a component, and how you can nest components based on routes. It was a new way of thinking about routes when I first came across it, and I've grown to really appreciate how powerful it can be to build clean app layouts.

Choosing a router mode

The caveat: because we're loading the Tito.js app into a third-party website, we weren't able to use Vue Router’s history mode. For that to work, we’d need to control server-side routes too, and that's not going to happen on a third-party site. When I first implemented the new widget, I decided to go with Hash mode. This would allow permalinks to widget URLs on the third-party site, like this:

https://example.org/#/tito/example-account/example-event/en/registrations/new

This is really cool. When you hit a URL like the one above with the widget installed, it loads the site as normal, but then the widget reads the hash URL and pops up, in the above case, a new registration flow.

Several customers got in touch though to say that there was a # being automatically appended to the end of their URLs. So if they visited https://example.org, the URL would change to https://example.org/#/

This is default behaviour of Vue Router, I'm guessing because the folks who implemented it didn't think anyone would be silly enough to use it to create permalinks on third party sites.

Introducing a proxy router

The PR that I closed fixes the above issue, while maintaining the ability to do permalinks. But how?!

Permalinks for the widget now look like this:

https://example.org/?tito=/example-account/example-event/en/registrations/new

So instead of Hash urls, the Tito URL is stored in a tito query param. The main Vue app watches for changes to the query param, and then routes exactly as it did before. How is this possible? Two routers!

The first router, the externalRouter uses history mode, but it doesn't have any routes:

import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);

const externalRouter = new VueRouter({
  mode: "history", // for not changing the host’s URL
});

export default externalRouter;

The second router, an internalRouter uses abstract mode:

import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);

// import routes here

const routes = [
  /// app routes here
];

const router = new VueRouter({
  mode: "abstract", // for not changing the host’s URL
  routes, // short for `routes: routes`
});

router.afterEach((to, from) => {
  if (tito.config && tito.config.iframe && tito.config.overlay) {
    window.parent.postMessage(
      {
        action: "route",
        route: to.path,
      },
      "*"
    );
  }
});

export default router;

Notice also that when the route changes and it's inside an iframe, it posts its current URL back up to the parent. The Tito.js has an iframe mode and a non-iframe mode, so we need to keep track of the URL potentially in two places.

Next, we have two bus instances of Vue that only exist to respond to changes in the URLs:

With the Internal Router:

import router from "src/config/router.js";

import Vue from "vue";
export default new Vue({
  router,
});

And with the External Router:

import externalRouter from "src/config/externalRouter.js";
import Vue from "vue";

import routeIfNotCurrent from "src/mixins/routeIfNotCurrent.js";

export default new Vue({
  router: externalRouter,

  mixins: [routeIfNotCurrent],

  watch: {
    "$route.hash": {
      handler() {
        if (this.$route.hash.startsWith("#/tito")) {
          const query = { ...this.$route.query };
          query["tito"] = this.$route.hash.replace("#/tito", "");

          this.routeIfNotCurrent({
            query: query,
          });
        }
      },
      immediate: true,
    },
  },
});

Here we also have a watcher that watches the # part of the URL. This supports the older URL structure, but redirects to the new. It's important because it allows folks to link directly to widget URLs via the hash without reloading the page.

The last piece of the puzzle is changing the tito part of the URL when the internal router changes:

import vueWithExternalRouter from "src/config/vueWithExternalRouter.js";

router.afterEach((to, from) => {
  if (to.path === "/" && Object.keys(to.query).length === 0) {
    vueWithExternalRouter.routeIfNotCurrent({
      query: null,
    });
    return;
  }
  vueWithExternalRouter.routeIfNotCurrent({
    query: {
      tito: to.fullPath,
    },
  });
});

routeIfNotCurrent is just a mixin that checks to see if we’re not already on that URL:

export default {
  methods: {
    routeIfNotCurrent(route) {
      if (!route) {
        return;
      }
      try {
        const toPath = this.$router.resolve(route).route.fullPath;
        const fromPath = this.$route.fullPath;
        if (route && toPath != fromPath) {
          this.$router.replace(route);
        }
      } catch (e) {
        console.warn("[tito]", `Error caught while routing to ${route}`, e);
      }
    },
  },
};

And that did the trick. Now when you load the widget, nothing happens to your URL, but when you do interact with it, you get permalinks in the form of the query param tito changing.