@curi/router

GitHub logoGitHub RepoNPM logoNPM Package

About

The @curi/router package provides functions for creating a single-page application's router.

Installation

npm install @curi/router

UMD scripts script are also available through Unpkg. You can access the package's exports using window.Curi.

API

prepareRoutes

The prepareRoutes function takes an application's routes and route interactions and returns an object. The returned object will be passed to createRouter.

import { prepareRoutes } from '@curi/router';

let routes = prepareRoutes([
  { name: "Home", path: "" },
  // ...
  { name: "Not Found", path: "(.*)" }
]);

prepareRoutes creates a reusable routing object, which means that it can be reused on the server instead of recompiling it for every request.

Arguments

routes

An array of route objects.

createRouter

The createRouter function is used to create a router.

import { createRouter } from "@curi/router";

let router = createRouter(browser, routes, options);

Arguments

history

A Hickory history function. The history guide provides more information on how to choose which history type is right for an application.

import { browser } from "@hickory/browser";

let router = createRouter(browser, routes);

routes

An array of prepared route objects describing all valid routes in the application.

let routes = prepareRoutes([
  { name: "Home", path: "" },
  { name: "About", path: "about" }
]);

let router = createRouter(browser, routes);

options

An optional object with additional properties that can be passed to the router.

sideEffects

An array of side effect objects.

import { createRouter, scroll } from "@curi/router";

let router = createRouter(browser, routes, {
  sideEffects: [scroll()]
});
external

Values that should be accessible to a route's resolve function respond functions.

Using external allows you to access APIs, data, etc. without having to import it in the module where the routes are defined.

let client = new ApolloClient();
let router = createRouter(browser, routes, {
  external: { client, greeting: "Hi!" }
});
let routes = prepareRoutes([
  {
    name: "User",
    path: "user/:id",
    resolve(match, external) {
      // use the external object to make a query
      return external.client.query();
    }
  }
]);
invisibleRedirects

When a response object has a redirect property, Curi will automatically navigate to the location specified by the property.

If the invisibleRedirects property is false (the default), Curi will emit the redirect response (any observers will be called with the response).

If invisibleRedirects is set to true, Curi will skip emitting the redirect; this effectively makes the redirect invisible to the application.

invisibleRedirects should always be false for server-side rendering, otherwise the application will render content for the incorrect location.

let routes = prepareRoutes([
  {
    name: "Old",
    path: "old/:id",
    respond({ params }) {
      // setup a redirect to the "New" route
      return {
        redirect: {
          name: "New",
          params
        }
      };
    }
  },
  {
    name: "New",
    path: "new/:id"
  }
]);

let router = createRouter(browser, routes, {
  invisibleRedirects: false
});
// navigating to "/old/2" will automatically redirect
// to "/new/2" without emitting a response for the Old route

Router

The router has a number of properties for you to use when rendering your application.

url

The url method is used to generate a URL string.

router.url({
  name: "User",
  params: { id: 1 },
  hash: "other"
});
Arguments

url takes a single argument, an object with details about the location to navigate to and how to navigate.

name

The name of a route.

params

An object of any route params for the named route (and any of its ancestors that require params).

hash

The hash string of a location.

query

The query value of a location.

The navigate method is used to navigate programmatically. It takes an object with the details of where and how to navigate.

router.navigate({ url: "/photos/123/456" });
// navigates to "/photos/123/456"
// using default "anchor" method

navigate takes a single argument, an object with details about the location to navigate to and how to navigate.

The URL string of the location to navigate to.

Any serializable state to attach to the location.

How to navigate. "push" appends the new location after the current one. "replace" replaces the current location. "anchor" is the default method and acts like clicking a link. This behavior is a mix of "push" and "replace" where the current location is replaced if the new location has the exact same URL.

finished is a function to call once the navigation has finished.

cancelled is a function to call if the navigation is superseded by another navigation.

These properties should only be provided when navigating to asynchronous routes. Synchronous routes navigate immediately, so there is no waiting time for the navigation.

once

Register a response handler that will only be called one time.

When a matched route is async (it has a resolve function), a response will not be created until the function has resolved. once is useful for delaying an application's initial render.

router.once(({ response }) => {
  // render the application based on the response
});
Arguments
Response Handler

A function that will be called once the router has generated a response. If the router has already generated a response, the function will be called immediately with the existing response. Otherwise, the function will be called once a new response is generated.

The function will be passed an object with three properties: the response, a navigation object, and the router.

Options
initial

When true (the default), the response handler will be called immediately if there is an existing response. When false, the response handler will not be called until a new response is generated.

router.once(responseHandler, {
  initial: false
});

observe

Register a response handler that will be called every time a response is generated.

When a matched route is async (it has a resolve function), a response will not be created until the function has resolved.

router.observe(({ response }) => {
  // render the application based on the response
});

observe returns a function, which can be called to stop observing.

let stopObserving = router.observe(fn);
// the router will call the response handler for all responses

stopObserving();
// the router no longer calls the response handler
Arguments
Response Handler

A function that will be called whenever a new response is generated. If a response already exists when the function is called, the response handler will be called immediately with the existing response.

Options
initial

When true (the default), the response handler will be called immediately if there is an existing response. When false, the response handler will not be called until a new response is generated.

router.observe(responseHandler, {
  initial: false
});

cancel

With navigating to an asynchronous route, there is a time between when a navigation begins and when the route's asynchronous actions have finished, during which a user may decide to cancel the navigation.

In a multi-page application, the browser updates the refresh button to a stop button. There is no equivalent functionality for single-page applications, so Curi provides a cancel function to roughly imitate the behavior.

cancel takes an observer function that will be called when navigation starts and when the navigation is finished. When the navigation starts, the observer function will be called with a function to cancel the navigation. When the navigation finishes, the function will be called with undefined.

Calling cancel returns a function to stop observing.

let stopCancelling = router.cancel(fn);
// fn will be called for async navigation

stopCancelling();
// fn will no longer be called
Arguments
Cancel Handler

A function that will be called when an asynchronous navigation begins and ends.

When the navigation begins, the function will be passed a function that can be called to cancel the navigation.

When the navigation ends, the function will be passed undefined.

router.cancel(fn => {
  if (fn === undefined) {
    // the navigation has finished/been cancelled
  } else {
    // calling fn will cancel the navigation
  }
});

Calling the cancel handler after the navigation has ended does nothing.

current

The current method returns the current response and navigation objects.

let router = createRouter(browser, routes);
let tooSoon = router.current();
// tooSoon.response === undefined
// tooSoon.navigation === undefined

router.once(({ response, navigation }) => {
  let perfect = router.current();
  // perfect.response === response
  // perfect.navigation === navigation
});

route

The route method is used to get the public data about a route. This is useful in conjuction with route interactions.

history

The route's history object.

external

The external value that was passed through createRouter's options.

announce

The announce side effect is used to announce navigation to screen readers. The announcement is done using an ARIA live region.

The side effect will create an element with a aria-live attribute and add it to the DOM. This element will be styled to not be displayed on screen (but not actually hidden) so that only screen readers detect it.

The announce function takes a single argument, which is a function that receives the object emitted by the router and returns the string that should be set for the screen reader to read.

The DOM element's aria-live attribute will be "assertive" by default, but you can use the side-effect factory's second argument to pass an alternative (i.e. "polite").

import { createRouter, announce } from '@curi/router';

let announcer = announce(
  ({ response }) => `Navigated to ${response.meta.title}`
);

let politeAnnouncer = announce(
  ({ response }) => `Navigated to ${response.meta.title}`,
  "polite"
);

let router = curi(history, routes, {
  sideEffects: [announcer]
});

scroll

The scroll side effect will scroll the page after a navigation.

When Curi is running in a browser, it relies on the History API to change locations. Navigating using the History API does not trigger scrolling to the top of the page after navigation, so this side effect scrolls for you.

Pop navigation, such as clicking the browser's back and forward buttons, will rely on the browser to correctly restore the scroll position.

import { createRouter, scroll } from "@curi/router";

let router = createRouter(browser, routes, {
  sideEffects: [scroll()]
});

title

The title side effect will set the document's title.

The function takes a single argument, which is a function that takes the object emitted by a router and returns the string to set as the title.

import { createRouter, title } from '@curi/router';

let router = createRouter(history, routes, {
  sideEffects: [
    title(({ response }) => {
      return `${response.meta.title} | My Site`;
    })
  ]
});

The recommended approach for determining a title is to have routes set their meta.title property in their respond method.

{
  name: "About",
  path: "about",
  respond() {
    return {
      body: About,
      meta: {
        title: "About"
      }
    }
  }
}
// when the About route matches, document.title = "About | My Site"

Route Objects

route.name

A string that will be used to identify a route. This must be unique for every route.

[
  { name: 'Home' },
  { name: 'Album' },
  { name: 'Not Found' }
];

route.path

A string pattern describing what the route matches. path strings should not have a leading slash.

[
  { path: "" }, // yes
  { path: "/" }, // no
]

A route's path is used to check if it matches a location's pathname. When the path matches, the route is selected.

Path matching is done using regular expressions compiled by path-to-regexp. A path can include parameters, which are dynamic variables that are parsed from a location's pathname. For advanced path formatting, please read path-to-regexp's documentaion.

[
  { name: 'Home', path: '' },
  // when the pathname is a/yo, albumID = "yo"
  { name: 'Album', path: 'a/:albumID' },
  // the path (.*) matches every pathname
  { name: 'Not Found', path: '(.*)' }
];

// don't include a leading forward slash
// { name: 'Home', path: '/' }

route.resolve

The resolve property is a function that returns a Promise. It is used to run asynchronous actions for a route prior to rendering.

A route with a resolve function is asynchronous, while one with no resolve functions is synchronous. You can read more about this in the sync or async guide.

Every time that a route with a resolve function matches, the route's resolve function will be called. Simple caching can be done with the once function from @curi/helpers, while more advanced caching is left to the user.

The function will be passed an object with the matched route properties: name, params, partials, and location.

let about = {
  name: 'About',
  path: 'about',
  resolve({ name, params, partials, location }) {
    return Promise.resolve("hurray!");
  }
};

The value returned by the resolve function will be passed to the route's respond function through its resolved property. If there is an uncaught error, resolved will be null and the error will be passed.

let about = {
  name: 'About',
  path: 'about',
  resolve({ name, params, partials, location }) {
    return Promise.resolve("hurray!");
  },
  respond({ resolved, error }) {
    if (error) {
      // there was an uncaught error in the resolve function
    }
  }
};

route.respond

A function for modifying the response object.

The object returned by a route's respond function will be merged with the route's intrinsic match properties to create the response object.

Only valid properties will be merged onto the response; everything else will be ignored. The valid properties are:

Arguments

options

A respond function is passed an object with a number of properties that can be useful for modifying the response.

{
  respond: ({ match, resolved, error }) => {
    // ...
  }
}
match

An object with the intrinsic route properties of a response.

name

The name of the matched route.

params

Route parameters parsed from the location.

partials

The names of any ancestor routes of the matched route.

location

The location that was used to match the route.

key

A two number tuple. The first number is the location's place in the session array. The second number starts and zero and is incremented by replace navigation ([1,0] would be replaced by [1,1]).

resolved

An object with the value returned by the route's resolve function.

If a route isn't async, resolved will be null.

// attach resolved data to the response
let user = {
  name: 'User',
  path: ':id',
  resolve({ params, location }) {
    return fetch(`/api/users/${params.id}`)
      .then(resp => JSON.parse(resp));
  },
  respond: ({ resolved }) => {
    return {
      data: resolved
    };
  }
}
error

If the route has a resolve function that throws an uncaught error, the error property will be that error. Otherwise, the property will be null.

Ideally, the resolve function will always catch its errors, but error serves as a safety check.

// check if any of a route's resolve functions threw
let user = {
  name: 'User',
  path: ':id',
  resolve({ params, location }) {
    return fetch(`/api/users/${params.id}`)
      .then(resp => JSON.parse(resp));
  },
  respond: ({ error, resolved }) => {
    if (error) {
      return {
        meta: {
          error
        }
      };
    }
    return {
      data: resolved.data
    };
  }
}

Return Value

body

Typically, the body is a component (or components) that will be rendered.

import Home from "./components/Home";
let routes = prepareRoutes([
  {
    name: "Home",
    path: "",
    respond() {
      return { body: Home };
    }
  },
  // ...
]);
// response = { body: Home, ... }
meta

An object whose properties are metadata about the response. This may include the status of the response (200, 301, etc.), a title string for the document, or a description to be set as a page's <meta name="Description">.

{
  respond(){
    return {
      meta: {
        title: "Some Page",
        description: "This is some page",
        status: 200
      }
    };
  }
}
data

Anything you want it to be.

{
  respond() {
    return { data: Math.random() };
  }
}
// response = { data: 0.8651606708109429, ... }
redirect

An object with the name of the route to redirect to, params (if required), and optional hash, query, and state properties.

The other values are copied directly, but redirect will be turned into a location object using the object's name (and params if required).

[
  {
    name: "Old Photo",
    path: "photo/:id",
    respond({ params }) {
      return {
        redirect: { name: "Photo", params }
      };
    }
  },
  {
    name: "New Photo",
    path: "p/:id"
  }
]
// when the user navigates to /photo/1:
// response = { redirect: { pathname: "/p/1", ... } }

The redirect property can also be used to specify a redirect to an external location. An external redirect object has only one property: exernalURL.

{
  name: "Redirects",
  path: "redirects",
  respond() {
    return {
      redirect: {
        externalURL: "https://example.com"
      }
    }
  }
}

Responses with an external redirect are always emitted, even when invisibleRedirects is true. The actual location changing is left to the application.

children

An optional array of route objects for creating nested routes.

Any child routes will be matched relative to their parent route's path. This means that if a parent route's path string is "one" and a child route's path string is "two", the child will match a location whose pathname is /one/two.

// '/a/Coloring+Book/All+Night' will be matched
// by the "Song" route, with the params
// { album: 'Coloring+Book', title: 'All+Night' }
{
  name: 'Album',
  path: 'a/:album',
  children: [
    {
      name: 'Song',
      path: ':title'
    }
  ]
}

params

When path-to-regexp matches paths, all parameters are extracted as strings. The params object is used to specify functions to transform the extracted value.

Properties of the route.params object are the names of params to be parsed. The paired value should be a function that takes a string (the value from the pathname) and returns a new value (transformed using the function you provide).

By default, each param is decoded using decodeURIComponent. A param function can be used to leave the param in its encoded form or to parse an integer param into a number.

let routes = prepareRoutes([
  {
    name: 'Number',
    path: 'number/:num',
    params: {
      num: n => parseInt(n, 10)
    }
  }
]);

// when the user visits /number/1,
// response.params will be { num: 1 }
// instead of { num: "1" }

Unnamed params are referred to by their index in the path.

let routes = prepareRoutes([
  {
    name: 'Not Found',
    path: '(.*)',
    params: {
      // skip decoding the unmatched value
      0: value => value
    }
  }
]);

pathOptions

An object for configuring how the path-to-regexp handles the path.

{
  name: "My Route",
  path: "my/:item",
  pathOptions: {
    match: {
      sensitive: false
    },
    compile: {
      encode: (value, token) => value
    }
  }
}

match

Properties for parsing the path into a regular expression.

You can see the options and their default values in the path-to-regexp documentation.

compile

For pathname generation, the options are passed through a compile object. There is only one possible option, which is an encode function for encoding params. The default encode function encodes params using encodeURIComponent.

extra

If you have any additional properties that you want attached to a route, use the extra property. You will be able to use route.extra in any custom route interactions.

let routes = prepareRoutes([
  {
    name: 'A Route',
    path: 'a-route',
    extra: {
      transition: 'fade'
    }
  },
  {
    name: 'B Route',
    path: 'b-route',
    extra: {
      enter: 'slide-right'
    }
  }
]);