raquo / waypoint

Efficient router for Laminar UI Library

Version Matrix


Join the chat at https://gitter.im/Laminar_/Lobby Maven Central

Waypoint is an efficient Router for Laminar using @sherpal's URL DSL library for URL matching and the browser's History API for managing URL transitions.

This is an early but functional version. While Laminar itself is quite polished, Waypoint might be a bit rough around the edges including the docs.

"com.raquo" %%% "waypoint" % "0.3.0"   // Requires Airstream 0.12.0 & URL DSL 0.3.2

Routing Basics

Different libraries use different language to describe the routing problem. The following are high level concepts to get us on the same page (ha!), not specific Scala types.

A URL is a well formed web address. The router deals only with URLs from the same origin (i.e. schema + domain + port) because the History API is unable to manage UI state across origins without a page reload.

A View is the content that should be rendered based on current Page. Typically it's a Laminar ReactiveElement.

A Page represents a specific UI State that a Route (and therefore a Router) can have. It is typically a case class with parameters matching a given Route, such as UserPage(userId: Int), or simply LoginPage.

A Pattern is a construct that can extract a tuple of data from URLs and compile a URL from a tuple of data. For example, root / "user" / segment[Int] / endOfSegments. In Waypoint, patterns are provided by the URL DSL library.

A Route is a class that defines how a class of Pages corresponds to a Pattern, and how to convert between the two. For example, Route.static(LoginPage, root / "login" / endOfSegments). Routes may be partial, i.e. match only a subset of the Pages in the class.

A Router is a class that provides methods to a) set the current Page and b) listen to changes in current Page. Because a router manages the browser's History API, you typically instantiate only one router per dom.window.

Rendering Views

So how do Views fit into all of the above? We need to render certain views based on the current page reported by the router. Here's our setup:

import com.raquo.laminar.api.L
import com.raquo.waypoint._
import upickle.default._
import org.scalajs.dom

sealed trait Page
case class UserPage(userId: Int) extends Page
case object LoginPage extends Page

implicit val UserPageRW: ReadWriter[UserPage] = macroRW
implicit val rw: ReadWriter[Page] = macroRW

val userRoute = Route(
  encode = userPage => userPage.userId,
  decode = arg => UserPage(userId = arg),
  pattern = root / "user" / segment[Int] / endOfSegments
val loginRoute = Route.static(LoginPage, root / "login" / endOfSegments)

val router = new Router[Page](
  routes = List(userRoute, loginRoute),
  getPageTitle = _.toString, // mock page title (displayed in the browser tab next to favicon)
  serializePage = page => write(page)(rw), // serialize page data for storage in History API log
  deserializePage = pageStr => read(pageStr)(rw) // deserialize the above
  $popStateEvent = L.windowEvents.onPopState, // this is how Waypoint avoids an explicit dependency on Laminar
  owner = L.unsafeWindowOwner, // this router will live as long as the window

Now, the naive approach to make this routing logic render some HTML is available in Laminar itself:

def renderPage(page: Page): Div = {
  page match {
    case UserPage(userId) => div("User page " + userId)
    case LoginPage => div("Login page.")

val app: Div = div(
  h1("Routing App"),
  child <-- router.$currentPage.map(renderPage)

  dom.document.getElementById("app-container"), // make sure you add such a container element to your HTML.

This works, you just need to call router.pushState(page) or router.replaceState(page) somewhere to trigger the URL changes, and the view will update to show which page was selected.

However, as you know, this rendering is not efficient in Laminar by design. Every time router.$currentPage is updated, renderPage is called, creating a whole new element. Not a big deal at all in this toy example, but in the real world it would be re-creating your whole app's DOM tree on every URL change. That is simply unacceptable.

You can improve the efficiency of this using Airstream's split operator operator, but this will prove cumbersome if your app has many different Page types. Waypoint provides a convenient but somewhat opinionated helper to solve this problem:

val splitter = SplitRender[Page, HtmlElement](router.$currentPage)
  .collectSignal[UserPage] { $userPage => renderUserPage($userPage) }
  .collectStatic(LoginPage) { div("Login page") }
def renderUserPage($userPage: Signal[UserPage]): Div = {
    "User page ",
    child.text <-- $userPage.map(user => user.userId)
val app: Div = div(
  h1("Routing App"),
  child <-- splitter.$view

This is essentially a specialized version of the Airstream's split operator. The big idea is the same: provide a helper that lets you provide an efficient Signal[A] => HtmlElement instead of the inefficient Signal[A] => Signal[HtmlElement]. The difference is that the split operator groups together models by key, which is a value, whereas SplitRender groups together models by subtype and refines them to a subtype much like a $currentPage.collect { case p: UserPage => p } would if collect method existed on Signals.

You should read the linked split docs to understand the general splitting pattern, as I will only cover this specialized case very lightly.

In the previous, "naive" example, we were creating a new div element every time we navigated to a new user page, even if we're switching from one user page to a different user's page. But in that latter case, the DOM structure is already there, it would be much more efficient to just update the data in the DOM to a different user's values.

And this is exactly what SplitRender.collectSignal lets you do: it provides you a refined Signal[UserPage] instead of Signal[Page], and it's trivial to build a single div that uses that $userPage signal like we do.

Page Hierarchy

SplitRender's collect and collectSignal use Scala's ClassTag to refine the general Page type into more specialized UserPage. You need to understand the limitations of ClassTag: it is only able to differentiate top level types, so in general your page types should not have type params, or if they do, you should know the limitations on matching those types with ClassTag.

To make the best use of SplitRender, you should make a base Page trait and have each of your pages as a distinct subclass. Static pages that carry no arguments can be objects, you can use SplitRender's collectStatic method to match them, it uses basic equality instead of ClassTag.

As your application grows you will likely have more than one level to your Page hierarchy. For example, you could have:

import com.raquo.waypoint._
sealed trait Page
sealed trait AppPage extends Page
sealed case class UserPage(userId: Int) extends AppPage
sealed case class NotePage(workspaceId: Int, noteId: Int) extends AppPage
case object LoginPage extends Page

// ... route and router definitions omitted for brevity ...

val pageSplitter = SplitRender[Page, HtmlElement](router.$currentPage)
  .collectSignal[AppPage] { $appPage => renderAppPage($appPage) }
  .collectStatic(LoginPage) { div("Login page") }
def renderAppPage($appPage: Signal[AppPage]): Div = {
  val appPageSplitter = SplitRender[AppPage, HtmlElement]($appPage)
    .collectSignal[UserPage] { $userPage => renderUserPage($userPage) }
    .collectSignal[NotePage] { $notePage => renderNotePage($notePage) }
    h2("App header"),
    child <-- appPageSplitter.$view
def renderUserPage($userPage: Signal[UserPage]): Div = {
    "User page ",
    child.text <-- $userPage.map(user => user.userId)
def renderNotePage($notePage: Signal[NotePage]): Div = {
    "Note page. workspaceid: ",
    child.text <-- $notePage.map(note => note.workspaceId),
    ", noteid: ",
    child.text <-- $notePage.map(note => note.noteId)
val app: Div = div(
  h1("Routing App"),
  child <-- splitter.$view

One reason for nesting splitters like this could be to avoid re-rendering a common App header. In this case it's just a simple h2("App header") element but in real life it could be complex subtree with inputs that you don't want to re-create when you're switching pages. In this last setup, h2("App header") will not be re-created as long as you navigate within AppPage pages. Without such nesting you would need to re-create the header when navigating from a UserPage to a NotePage (or vice versa) even though both should have the same header.

SplitRender offers several methods: collect, collectSignal and collectStatic, use the ones that make more sense for your pages. Mixing them is fine of course.

Note: SplitRender is a construct made only of reactive variables. It does not know anything about routing, what the current URL is, etc. You give it a signal of A and a way to refine that into B, and you get a signal of B with $view.

Using Waypoint

URL patterns and the matching functionality are provided by the URL DSL library. All the methods that you need are defined on the com.raquo.waypoint package object, so just import that:

import com.raquo.waypoint._
// this matches all routes under /hello
root / "hello"

// this matches just /hello and /hello/ (plus whatever query params)
root / "hello" / endOfSegments

// this matches urls like /user/123 into Int
root / "user" / segment[Int] / endOfSegments

// this matches urls like /workspace/123/subsection/info into (Int, String)
root / "workspace" / segment[Int] / "subsection" / segment[String] / endOfSegments

// this matches urls like:
//   /workspace/123?query=hello
//   /workspace/123?query=hello&mode=1 
// note the `.?` invocation on the `mode` param: this means it's optional, modelled as Option[Int]
(root / "workspace" / segment[Int] / endOfSegments) ? (param[String]("query") & param[Int]("mode").?)

How to use such patterns to build routes:

import com.raquo.waypoint._
import urldsl.vocabulary.UrlMatching

Route.static(LoginPage, root / "login" / endOfSegments)

Route[UserPage, Int](
  encode = userPage => userPage.userId,
  decode = arg => UserPage(userId = arg),
  pattern = root / "user" / segment[Int] / endOfSegments

Route[WorkspacePage, (Int, String)](
  encode = workspacePage => (workspacePage.id, workspacePage.subsection),
  decode = args => WorkspacePage(id = args._1, subsection = args._2),
  pattern = root / "workspace" / segment[Int] / "subsection" / segment[Sting] / endOfSegments

Route.withQuery[WorkspaceSearchPage, Int, (String, Int)](
  encode = page => UrlMatching(path = page.id, params = (page.subsection, page.mode)),
  decode = args => WorkspacePage(id = args.path, subsection = args.params._1, mode = args.params._2),
  pattern = (root / "workspace" / segment[Int] / endOfSegments) ? (param[String]("query") & param[Int]("mode"))

Route.onlyQuery[SearchPage, String](
  encode = page => page.query,
  decode = arg => SearchPage(query = arg),
  pattern = (root / "search" / endOfSegments) ? (param[String]("query"))

Each of the above results in a Route[Page, Args] with the precise types. You can ask these routes to parse argsFromPage, get relativeUrlForPage, pageForAbsoluteUrl, or pageForRelativeUrl. But normally you would build a Router like we did in Rendering Views, passing it a list of all the routes you created.

Then you can change the document URL with router.pushState(newPage) and router.replaceState(newPage), as well as get URLs for pages with router.absoluteUrlForPage and router.relativeUrlForPage (e.g. if you want to put that URL in a href attribute). You can even ask the router what Page, if any, matches a given url with router.pageForAbsoluteUrl or router.pageForRelativeUrl, or react to URL changes by listening to router.$currentPage signal (it's a StrictSignal so its current value is always available at router.$currentPage.now()).

Routing Extras

Partial Routes

Waypoint's API design is centered around matching pages by ClassTag, but under the hood we just use partial functions. So, you can create routes like this too, and yes, they can actually be partial, i.e. cover only a subset of the page type. For example, here is a route that matches pages only for big numbers.

Route.applyPF[DocsPage, Int](
  matchEncode = { case DocsPage(NumPage(i)) if i > 100 => i },
  decode = { case arg if arg > 100 => DocsPage(NumPage(arg)) },
  pattern = root / "docs" / "num" / segment[Int] / endOfSegments

When using partial matchers you need to be careful that the "partiality" of matchEncode and decode is symmetric, otherwise you might end up with a route that can parse a page from a URL but can't encode that same page into a URL (or vice versa).


Imagine your web app has many routes with the same query params. For example, your Laminar web app might have a documentation section where every route is expected to have a "lang" query param to indicate the selected "language" and a "version" param to indicate the product version (unrealistic doc standards, I know).

You could add these two params to every page type that needs them and to every route for those page types, but that could be annoying. So instead, you can create a ContextRouteBuilder which will let you specify the necessary types / conversions / patterns only once.

To achieve this, ContextRouteBuilder introduces the concept of Bundle, which is basically a... bundle of (Page, Context), where Page is the usual Waypoint Page, and Context is a data structure that contains all the shared query params, such as "language" and "version" in our example.

See example usage in tests, specifically ContextRouteBuilderSpec.

Error Handling

Failing to Match Routes

If none of the routes match on initial page load, you can still render a page using routeFallback constructor param. The URL will not be updated, but router.$currentPage will emit the resulting page. If routeFallback throws, which is the default behaviour, router.$currentPage will be put into error state. If you want to throw for logging purposes, but don't want this to happen, return the current page and throw inside a setTimeout.

Similarly, if during user's navigation we encounter a History API state record that deserializePage throws on, you can handle it using deserializeFallback. It throws too by default, with the same effect as routeFallback.

router.$currentPage can also be in an errored state if the initial URL does not match the origin. Both of those are taken from dom.window.location by default so this shouldn't be an issue.

Rendering Error Pages

routeFallback is useful to catch complete garbage URLs that match none of the routes, but you also want more refined control over invalid parameters.

For example, imagine you're matching a route like /user/<userId>. When the matching happens you don't know whether a user with <userId> actually exists, you will only know that after an asynchronous delay. So, your userRoute should successfully match this path, rendering an element that makes an AJAX request for this user's info on mount. Some milliseconds later when the AJAX response comes in you can either proceed to render the user or trigger a failure if AJAX resulted in an error. More specifically:

  • You could call router.forcePage(NotFoundPage(title, comment)) to force the router to emit the provided page without updating the URL (although document title will be updated unless the page's title is not empty). This pattern is useful to show a full page error screen without redirecting the user to a different URL or messing with the navigation history.

  • You could call router.pushState or router.replaceState to render a different page with a redirect. Sometimes this is required, but for UX purposes you should avoid redirecting users to useless URLs like /404 because this prevents the user from manually fixing the URL in the address bar. A pushState redirect can also interfere with the user's ability to use the browser back button.

So, router.forcePage is often the preferred way.


Pages carrying state not reflected in the URL

Suppose you want to "remember" vertical scroll position on a certain page, so that when you navigate away from it and then come back, it's restored. Instead of maintaining complex global variables or cookies or whatever, just include this information in the Page.

Pages are serialized into History API state records, so when the user uses browser back button to come back to a page that he's already been to, it's not just the old URL that's restored, it's the whole Page state. On the contrary, if the user reloads the page or clicks a link that causes a page load instead of the History API transition, the Page state will be parsed from the URL, and since the URL does not include scroll position in our case, that will need to be the default scroll position of zero.

Route[NotePage, (Int, Int)](
  encode = page => (page.libraryId, page.noteId), // scroll position is not written into the URL args
  decode = args => NotePage(libraryId = args._1, noteId = args._2, scrollPosition = 0), // default scroll position that will be inferred from the URL
  pattern = root / "app" / "library" / segment[Int] / "note" / segment[Int] / endOfSegments

Remember that your code needs to actually scroll to the desired scroll position when loading the page. You can probably just do this when switching from a different type of page to the type of page that remembers its scroll position. Treat scroll position as a sort of "uncontrolled input" in React terms, if that makes sense.

Lastly, normally you fire router.pushState to update the current page. But in case of updating current scroll position, you should instead fire router.replaceState, otherwise you will litter your browser history with a bunch of useless scrolling records.

Waypoint Without Laminar

Perhaps ironically, Waypoint does not actually depend on Laminar, only on Airstream.

All you need to use Waypoint without Laminar is provide a stream of dom.OnPopState events, which is very easy, just make one using Airstream's DomEventStream.


Nikita Gazarov – @raquo

License & Credits

Waypoint is provided under the MIT license.

Waypoint uses URL DSL for URL matching, also provided under the MIT license.