@fovea/router

A modern router built with Fovea

Stats

StarsIssuesVersionUpdatedCreatedSize
@fovea/router
881.0.1373 years ago3 years agoMinified + gzip package size for @fovea/router in KB

Readme

Downloads per month Dependencies NPM Version Contributors MIT License Support on Patreon

@fovea/router

A modern router built with Fovea

Description

@fovea/router is the official Router for Fovea. Features include:

  • Component-based router configuration
  • Nested route mapping
  • Lazy loading of matched routes
  • Named routes, params, query parameters, redirects, and aliases.
  • Global and per-route guards
  • Navigation lifecycle hooks (For example for transition animations)

Install

NPM

$ npm install @fovea/router

Yarn

$ yarn add @fovea/router

Basic example

The following example shows how to setup a simple Router that can route between a couple of views:

HTML
<!-- inside app-component.html -->
<h1>My awesome app</h1>
<!-- Matched routes goes inside the <router-outlet> -->
<router-outlet></router-outlet>
<!-- routerLinks annotate <a> tags -->
<a href="/" *router-link>Go to Home</a>
<a href="/profile" *router-link>Go to Profile</a>
Javascript/Typescript
// Inside app-component.ts
import {templateSrc} from "@fovea/core";
import {Router, RouterLink} from "@fovea/router";
@templateSrc("./app-component.html")
class AppComponent extends HTMLElement {
  /**
   * Instantiate the Router after the component has been attached to the DOM.
   */
  connectedCallback() {
    Router.initialize({
      // root can be any element that has a router-outlet element in its' local DOM.
      root: this,
      routes: [
        // Your routes can be configured here
        {
          path: "/",
          name: "home",
          component: () => import("./home-component")
        },
        {
          path: "/profile/:userId",
          name: "profile",
          component: () => import("./profile-component")
        },
        // An aliased route can provide custom params and query params to another configured route
        {
          path: "/profile/me",
          alias: {name: "profile", params: {userId: -1}}
        }
      ]
    });
  }
}

Route patterns

Quite often, the same routes should be matched for different paths. For example, imagine a /profile route that should display data about a user. We can provide dynamic segments to our paths to indicate the dynamic parts of a URL by prefixing those parts with a ::

const profileRoute = {
  path: "/profile/:userId",
  name: "profile"
};

The :userId part is dynamic. For example, /profile/1 matches the route, and so does /profile/2. The dynamic parts are called Params, and these are provided to the matched components so that they can use them, for example to fetch user data from an API.

Here's a few examples of patterns and paths that matches them as well as the Params that will be provided to the matched components:

Pattern Matched path Params
/profile/:userId /profile/1 {userId: 1}
/posts/:postId/comments/:commentId /posts/1/comments/3 {postId: 1, commentId: 3}

Matching the same Route with different Params

The Router will create a new instance of the component matched by a Route each time the Params change, rather than reusing the component instance and passing the new values to it like some other routers do. This makes it possible to animate transitions between multiple instances of the same Route, but with different Params (for example, from one Profile page to another).

Matching priority

The Routes will be matched in the priority they are declared in. This means that if multiple Routes are matched by the same path, the route that is declared the earliest will be matched.

Nested/Child Routes

Apps are frequently composed of top-level routes (for example /, /profile, etc), and each of those may declare a routing hierarchy of their own. For example, the component matched by the /profile route may wish to optionally display the posts, comments, and other contributions by a user on the profile page and make sure that they can be routed to. For example, consider this markup

<h1>John Doe's Profile</h1>
<hr>
<nav>
    <ol>
        <li><a href="profile.posts" *router-link="params: ${params}">Posts</a> </li>
        <li><a href="profile.comments" *router-link="params: ${params}">Comments</a></li>
        <li><a href="profile.likes" *router-link="params: ${params}">Likes</a></li>
    </ol>
</nav>
<article>
    Content goes here
    <router-outlet></router-outlet>
</article>

We will get into the details of the markup later when we describe RouterLinks. Here's how it may render with some very basic styling Nested Route example The Router supports this kind of routing seamlessly through Nested routes. These are declared by passing routes to the children property of a route. Note that this works recursively. Any route can have child routes, even if it is a child route itself:

const profileRoute = {
  path: "/profile/:userId",
  name: "profile",
  component: () => import("./profile-component"),
  children: [
    {
      path: "/posts",
      name: "profile.posts",
      component: () => import("./post-list-component")
    },
    {
      path: "/comments",
      name: "profile.comments",
      component: () => import("./comment-list-component")
    },
    {
      path: "/likes",
      name: "profile.likes",
      component: () => import("./like-list-component")
    }
  ]
};

The paths of child routes will be appended to the path of their parent route. In the above example, the child route with the name profile.posts will match the pattern: /profile/:userId/posts.

Default children

If a route contains nested children, you may want one to decide which child to default to, even if it isn't strictly matched by the URL. In the example above, let's say we always want the posts-list-component to load by default if none of the other children are matched by the URL. This way, navigating to the /profile route will always display some content.

To do this, use the defaultChild property for a route that has children:

const profileRoute = {
  // ...
  defaultChild: "profile.posts",
  children: [
    {
      // ...
      name: "profile.posts"
      // ...
    }
    // ...
  ]
};

The defaultChild should be given a name for a child route.

Named routes

Sometimes it is convenient to associate a name with a route, for example if the path is complex, include several Params, or if the route should receive a specific combination of query parameters. There can be plenty of reasons why you want to refer to a name for a route, and for all these reasons, use the name property when a route is declared:

const someRoute = {
  path: "/some/complex/path/:someParam",
  name: "foo"
};

Aliased Routes

You can use aliases to refer to another route by the pattern for the aliased route. This makes it possible to have multiple routes with different paths, but with the same behavior. It also enables adding routes with special names that mask more complex paths. For example, in the basic example, we introduced the following route configuration:

const routes = [
  {
    path: "/profile/:userId",
    name: "profile",
    component: () => import("./profile-component")
  },
  {
    path: "/profile/me",
    alias: {name: "profile", params: {userId: -1}}
  }
];

Here, the /profile/me path is special in that it isn't a user ID, but it makes perfect semantic sense nonetheless. Here, an aliased route is a great way of referring to the /profile route with a URL that is more convenient.

Redirect routes

Redirect routes behave exactly like aliased routes, but rather than preserve the path provided by the redirected route, the path will be rewritten to the path of the route it redirects to. This mimics the behavior of standard HTTP redirects. For example:

const routes = [
  {
    path: "/",
    name: "home",
    component: () => import("./home-component")
  },
  {
    path: "/feed",
    redirect: {name: "home"}
  }
];

Navigation lifecycle hooks

Components that can be mapped to a Route should implement the IRouterTarget interface. The relevant navigation lifecycle hooks will be invoked on the matched components if they implement them. The lifecycle hooks are:

Lifecycle hook Description
onNavigateTo? (options: IRouteInstanceNavigateToOptions): Promise<void> Invoked when the component is attached and will replace any existing route. It receives options such as the Params and query parameters as an argument. Must return a Promise. The navigation change will be halted until the Promise is resolved, so you can use this hook to animate the entry of the component.
onNavigateFrom? (options: IRouteInstanceNavigateOptions): Promise<void> Invoked when the component is about to become detached. Must return a Promise. The navigation change will be halted until the Promise is resolved, so you can use this hook to animate the exit of the component.

Receiving and reacting to Params within components

The Params that is matched by a Route will be provided to the component through the navigation lifecycle hook onNavigateTo (see Navigation lifecycle hooks). For example, to store a reference to one or more of the given Params inside a component as @props, and to react to changes, the following code example will work:

import {prop, onChange} from "@fovea/core";
import {IRouteInstanceNavigateToOptions} from "@fovea/router";
class ProfileComponent extends HTMLElement {
  @prop userId: number;

  public async onNavigateTo({params}: IRouteInstanceNavigateToOptions): Promise<void> {
    this.userId = params.userId;
  }

  @onChange("userId")
  async onUserIdChanged(): Promise<void> {
    // Do some stuff with the new userId
  }
}

Guards

Guards are functions or methods that checks if a route can be navigated to. This is useful, for example to restrict routes from access from unauthenticated users. A guard may return a boolean, indicating whether or not to allow navigation to the associated route, or it may return another route to redirect to. A guard receives two arguments: The new state, and the current state (if any).

For example:

const routes = [
  {
    path: "/profile/:userId",
    name: "profile",
    component: () => import("../profile/profile-component"),
    guards: [
      async (stateInput, currentState) => {
        if (isAuthenticated) {
          return true;
        }
        // If the user is not authenticated, redirect to the 'login' page
        return {name: "login"};
      }
    ]
  },
  {
    path: "/login",
    name: "login",
    component: () => import("../login/login-component"),
    guards: [
      async (stateInput, currentState) => {
        if (!isAuthenticated) {
          return true;
        }
        // If the user is already logged in, redirect to the 'home' route
        return {name: "home"};
      }
    ]
  },
  {
    path: "/",
    name: "home",
    component: () => import("../home/home-component")
  }
];

Guard order and short-circuiting

Guards are automatically combined. For a navigation attempt to pass, all guards must return true. This means that even though one of your guards will accept navigation to the route, it can still be rejected if another one fails. For nested routes, the chain of guards are tested from parent to child.

For example, of the route with the path /foo/bar is a child of the route with path /foo, navigation to /foo/bar will be rejected if navigation to /foo is rejected.

Global Guards

Global guards are guards that will be invoked for every navigation attempt. This can be useful if all routes need to pass some basic validation such as an authentication check. Global guards are provided directly in the configuration provided to new Router instances:

Router.initialize({
  // ...
  guards: [
    async (stateInput, currentState) => {
      // Handle each navigation attempt within this guard
    }
  ],
  routes: [
    // ...
  ]
});

Route Guards

Route Guards are guards that are related to specific routes. These will only be checked upon navigation attempts to the route the guard is associated with. For example:

Router.initialize({
  // ...
  routes: [
    {
      path: "/foo",
      name: "foo",
      component: () => import("../foo/foo-component"),
      guards: [
        async (stateInput, currentState) => {
          // Handle each navigation attempt for this specific route within this guard
        }
      ]
    }
  ]
});

RouterOutlet

The <router-outlet> element is a container for instances of components matched by routes. The element provided to the Router in its initialization options must have a <router-outlet> element in its local DOM. The same goes for any route that declares child roots of its own ("nested routes"). When a new route is matched, the matched component will be instantiated and attached to a <router-outlet> immediately. Any existing routes will be detached as soon as all navigation lifecycle hooks have finished executing (for example, to animate entry and exit of the new route).

RouterLink

Anchor (<a>) tags annotated with the RouterLink Custom Attribute will use the Router for navigation. RouterLinks will use the href attribute on the <a> tag to decide which path to navigate to. Href attribute values prefixed with a / will be treated as paths, and all other paths will be treated as names. For example:

<a href="/some_path" *router-link></a>
<a href="some_name" *router-link></a>

In the above example, the first <a> tag will attempt to navigate to the route that has the path: /some_path, while the second <a> tag will attempt to navigate to the route that has the name some_name.

Passing options to RouterLinks

RouterLinks may take options. For example:

<a href="some_name" *router-link="params: ${ {userId: 2} }; replace: true"></a>

Here, the route with the name some_name will be matched, and the params {userId: 2} will be provided. The replace option will replace the current navigation history state with the new one, rather than push it onto the stack. The full list of options is:

Option Description
params A dictionary of the Params to provide to the route
query The query parameters to provide to the route
replace Whether to replace the current history state, rather than push a new one onto the stack
title The title to use for the new Route (the text that will appear in the browser tab)

Programmatic navigation

You don't have to use RouterLinks for all navigation purposes. You can just as easily use Router directly through its public interface.

push(options: RouterPushOptions): Promise<boolean>

The push method will push a new route onto the navigation history stack and navigate to it. For example:

await router.push({
  path: "/some_path",
  params: {someParam: "foo"},
  query: {foo: "bar"},
  title: "This is an awesome title!"
});

replace(options: RouterPushOptions): Promise<boolean>

The replace method behaves exactly like the push method (see above), but replaces the current navigation state, rather than pushing a new one onto the history stack.

go(n: number): void

Goes n amount of states back or forth in the navigation history. For example, to go the route before the previous route:

router.go(-2);

pop(): void

Pops the current state from the stack and navigates to the previous route. This is equivalent to calling router.go(-1).

addRoutes (routes: RouteInput[]): void

Adds additional routes to the Router. This is useful is some routes should only be conditionally hooked up depending on the state of your app.

dispose(): void

Removes all attached routes and empties the Router state all-together.

length: number

Retrieves the size of the navigation history.

If you find any bugs or have a feature request, please open an issue on github!

The npm package download data comes from npm's download counts api and package details come from npms.io.