Waypoint
Efficient router for Laminar UI Library
Install / Use
/learn @raquo/WaypointREADME
Waypoint
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.
Unlike Laminar itself, Waypoint is quite opinionated, focusing on a specific approach to representing routes that I think works great. Frontroute is another routing alternative.
Waypoint can be used with other Scala.js libraries too, not just Laminar. More on that at the bottom of this document.
Waypoint docs are not as exhaustive as Laminar's, but we have examples, and Waypoint is very, very small, so this shouldn't be a big deal. Just make sure you understand how the browser's History API works.
"com.raquo" %%% "waypoint" % "10.0.0-M1" // Depends on Laminar 17.2.0 & URL DSL 0.6.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]. 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"). 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.waypoint._
import upickle.default._
import org.scalajs.dom
sealed abstract class Page(val title: String)
case class UserPage(userId: Int) extends Page("User")
case object LoginPage extends Page("Login")
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]
)
val loginRoute = Route.static(LoginPage, root / "login")
object router extends Router[Page](
routes = List(userRoute, loginRoute),
getPageTitle = page => page.title, // (document title, displayed in the browser history, and in the 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
)
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.currentPageSignal.map(renderPage)
)
render(
dom.document.getElementById("app-container"), // make sure you add such a container element to your HTML.
app
)
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.currentPageSignal 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, 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.currentPageSignal)
.collectSignal[UserPage] { userPageSignal => renderUserPage(userPageSignal) }
.collectStatic(LoginPage) { div("Login page") }
def renderUserPage(userPageSignal: Signal[UserPage]): Div = {
div(
"User page ",
child.text <-- userPageSignal.map(user => user.userId)
)
}
val app: Div = div(
h1("Routing App"),
child <-- splitter.signal
)
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 currentPageSignal.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 userPageSignal 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 standard == value 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.currentPageSignal)
.collectSignal[AppPage] { appPageSignal => renderAppPage(appPageSignal) }
.collectStatic(LoginPage) { div("Login page") }
def renderAppPage(appPageSignal: Signal[AppPage]): Div = {
val appPageSplitter = SplitRender[AppPage, HtmlElement](appPageSignal)
.collectSignal[UserPage] { userPageSignal => renderUserPage(userPageSignal) }
.collectSignal[NotePage] { notePageSignal => renderNotePage(notePageSignal) }
div(
h2("App header"),
child <-- appPageSplitter.signal
)
}
def renderUserPage(userPageSignal: Signal[UserPage]): Div = {
div(
"User page ",
child.text <-- userPageSignal.map(user => user.userId)
)
}
def renderNotePage(notePageSignal: Signal[NotePage]): Div = {
div(
"Note page. workspaceid: ",
child.text <-- notePageSignal.map(note => note.workspaceId),
", noteid: ",
child.text <-- notePageSignal.map(note => note.noteId)
)
}
val app: Div = div(
h1("Routing App"),
child <-- pageSplitter.signal
)
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, `collectSi
