Laminar v17.2.0
New Airstream features: splitting by pattern match; LocalStorage Vars, other derived Vars, mapping strict signals without an owner, and more.
Laminar is a Scala.js library for building web application interfaces and managing UI state. Learn more in a few short examples or one long video.
Releases
- Laminar 17.2.0
- Airstream 17.2.0
- Waypoint 9.0.0
- raquo/buildkit 0.1.0
- raquo/scalafmt-config 0.1.0
News
Joining HeartAI
I am very excited to announce that I have joined the HeartAI team! π Together we will be working on complex Laminar applications β primarily for ICU wards and other hospital settings. HeartAI have been sponsoring Laminar development for more than a year now, enabling me to work on more Laminar and Airstream features, as well as learning materials like the Laminar full stack demo.
Ecosystem News
Very impressive developments from our community members β check them out!
Libraries
- Laminar Form Derivation β Derive Laminar web forms with Magnolia by Olivier Nouguier
- Laminouter β New Laminar URL router by @felher
- Scalawind β Zero-Runtime Typesafe Tailwindcss in Scala by Tu Nguyen
Web components
- Laminar MDUI components β Bindings for MDUI web components by Kewenchao
- scala-web-components-codegen β A more generic web components generator by Johannes Karoff
Templates
- raquo/scalajs.g8 β My own Scala.js / Laminar / CSS template, with the latter two optional.
- zio-scalajs-laminar.g8 β Template by Olivier Nouguier
Open source apps
- Scala.io website β source β By Lucas Nouguier et al.
- Imperial Todo App β Demo app with β¨AIβ¨ by Anton Sviridov from his talk below
- Mimalyzer β Are your changes binary/TASTY compatible in Scala? β by Anton Sviridov
- Sainte-LaguΓ« β Open source Laminar app to compute seat allocation used in some elections by @felher
- Planet of the Apes β Test your franchise knowledge β by Anton Sviridov
- Zio, Laminar and Tapir example app by Olivier Nouguier
Articles, Tutorials, etc.
- Scala.js talk at Imperial College β Slides, code, live demo app β by Anton Sviridov
- How to Build a ZIO Full-Stack Web Application by Olivier Nouguier
New Airstream Features
Mapping StrictSignal-s
You can now mapLazy
strict signals similarly to how you can zoomLazy
into a Var, and get back another StrictSignal without needing an owner:
val formVar = Var(Form(...))
val formSignal: StrictSignal[Form] = formVar.signal
val fieldSignal: StrictSignal[FieldValue] = formSignal.mapLazy(_.field) // Still a StrictSignal!
fieldSignal.now() // FieldValue
This helps manage more of your state without messing with owners / subscriptions and observable composition.
Q: Nikita, these types and method names don't make sense anymore.
A: Yes. Just as with zoomLazy
, we are evolving core Airstream capabilities within the 17.x line, which means that I can't just rename the type StrictSignal
, and can't make the old map
method return a more specific subtype of Signal
. I plan to clean this up and expand the owner-less functionality in the next major version, so please bear with the lazy naming for now.
Splitting Observables by pattern match ("by type")
You know how in Airstream you can split
a Signal[Seq[Item]]
into N individual Signal[Item]
-s, one for every unique item, identified by a key like _.id
β to efficiently render it in Laminar? Well, Hoang Dinh has implemented a similar transformation, but for splitting an Observable[ADT]
β where ADT
is e.g. a sealed trait with several subclasses β into N Signal[Subtype]
, one for each subtype.
Take a look at a very simple example:
sealed trait Page
object HomePage extends Page
object LoginPage extends Page
case class UserPage(userId: Int) extends Page
val pageSignal: Signal[Page] = ??? // router.currentPageSignal
val elementSignal: Signal[HtmlElement] =
pageSignal
.splitMatchOne
.handleValue(HomePage) {
div(h1("Home page"))
}
.handleValue(LoginPage) {
div(h1("Login page"))
}
.handleType[UserPage] { (initialUserPage, userPageSignal) =>
div(
h1("User #", text <-- userPageSignal.map(_.id))
)
}
.toSignal
Under the hood, this is equivalent to something like:
val elementSignal: Signal[HtmlElement] =
pageSignal
.map {
// condition => (handlerIndex, handlerInput)
case HomePage => (0, ())
case LoginPage => (1, ())
case up: UserPage => (2, up)
}
.splitOne(key = _._1) { (..., ..., ...) =>
// Return one of the div-s, properly wired and precisely typed
}
You may recognize that this looks similar to Waypoint's SplitRender
functionality, and you'll be right βhandleValue
is similar to Waypoint's collectStatic
and handleType
is similar to collectSignal
β but, importantly, Waypoint's implementation has several problems:
- For
collectSignal
, it relied onClassTag
for type matching, so it isn't super safe around generic type params - It could not warn you about non-exhaustive matches
The new implementation is macro-based, and it works by rebuilding / rearranging the case
statements you provide into a single match
block, so you get all the features and guarantees of regular Scala pattern matching.
Yes, this new functionality is actually more generalized than just matching by type or value: you can generate arbitrary case statements using handleCase
(not shown here) β see the new documentation section for that.
But that's not all!
You can split-by-pattern-match not only an Observable[ADT]
, but also an Observable[Seq[ADT]]
β so if you have an observable with a list of items, and you need a separate render callback for each item, and each item in the collection can be one of several types, each requiring different rendering β yes, you can do that now, all in one go, with splitMatchSeq
, for example:
trait Item { val id: String }
case class Stock(ticker: String, currency: String, value: Double) {
override val id: String = ticker
}
case class FxRate(currency1: String, currency2: String, rate: Double) {
override val id: String = currency1 + "-" + currency2
}
val itemsSignal: Signal[Seq[Item]] = ???
val elementsSignal: Signal[Seq[HtmlElement]] =
modelsSignal
.splitMatchSeq(_.id)
.handleType[Stock] { (initialStock, stockSignal) =>
div(
initialStock.id + ": ",
text <-- stockSignal.map(_.value),
" " + initialStock.currency
)
}
.handleType[FxRate] { (initialRate, rateSignal) =>
div(initialRate.id, ": ", text <-- rateSignal.map(_.rate))
}
.toSignal
children <-- elementsSignal // in Laminar
I am super pumped that such advanced Observable transformations are now a part of Airstream core. This is the first usage of macros in Airstream, and it's a good use case for them. Huge thanks to Hoang for working through all this!
While the rest of Airstream is cross compiled for both Scala 2.13 and Scala 3, these new macro-based features are for Scala 3 only. We do not plan to port this new functionality to Scala 2.13 as we are not proficient in Scala 2 macros. In Scala 2.13, you can use Waypoint's general-purpose SplitRender
functionality to similar effect (except for splitMatchSeq
, which is not available).
Local Storage Vars
Local Storage is a browser API that lets you persist data to a key-value client-side storage. This storage is shared between and is available to all tabs and frames from the same origin within the same browser.
Airstream now offers persistent Vars backed by LocalStorage, accessed via WebStorageVar.localStorage
, e.g.:
val showSidebarVar: WebStorageVar[Boolean] =
WebStorageVar
.localStorage(key = "showSidebar", syncOwner = None)
.bool(default = true)
LocalStorage Vars can automatically sync their values between multiple browser tabs. See live demo. For details, see the new documentation section.
And yes, we support SessionStorage too.
More Var Features
Distinct Vars
The distinct
operator (including all its variations like distinctByFn
) is now available on Vars. For Vars, it filters both reads and writes, making it easy to create a state Var that only updates when its content actually changes to a different value: val formVar = Var(initial = Form.empty).distinct
.
For implementation details, see the new documentation section.
Isomorphic Transformations
You could already zoomLazy
into a Var to e.g. focus on a particular field, and now you can also bimap
the Var to apply an isomorphic transformation, for example:
val fooVar: Var[Foo] = Var(Foo(123, "yo"))
val jsonVar: Var[String] = fooVar.bimap(getThis = Foo.toJson)(getParent = Foo.fromJson)
See the new documentation section.
Var splitByIndex and splitOption
These useful split
variations were ported from Signals to Var-s by Paul-Henri Froidmont β thanks!
You Can Now Subclass SourceVar
This lets you create Var-s with custom public methods, for example the new WebStorageVar is implemented that way.
Small Laminar Fixes
- Fix: Avoid instantiating some internal comment nodes unless / until they are needed.
New Waypoint Features
- Build: Waypoint depends on Laminar now (not just Airstream)
- New:
navigateTo
example method from the docs is now available onRouter
β it provides a convenient modifier for Laminar elements that sets a reasonable onClick handler and href on the element. Documentation - Unlike the previous example in the docs, it does not reset scroll position β do that yourself by observing
currentPageSignal
- Migration:
Router
constructor is now a single argument list,popStateEvents
andowner
now have default values.
- New:
- New: Total and Partial route types, to improve type-safety for the total routes β by ArtΕ«ras Ε lajus β thank you!
- New:
route.argsFromPageTotal
androute.relativeUrlForPage
available onRoute.Total
subtype ofRoute
. - Migration: If
Route.static
complains that it can't findValueOf
for your page type, it's because your type is not a singleton likeobject LoginPage
, so we can't make a total route for it. UseRoute.staticPartial
instead.
- New:
- New:
Router.replacePageTitle
β useful when you need to fetch data before you know what the page title should be. - Fix:
SplitRender.signal
should be a lazy val, not def.
Scala.js & Laminar giter8 Template
A few people asked for a bare-bones Laminar giter8 template β and here it is: raquo/scalajs.g8 β and you can actually opt-out of Laminar here, so it's good for plain Scala.js too. The main value here is the quick sbt + Vite setup that gets you to writing Laminar apps in a moment.
Scala.js has its own vite.g8. Compared to that, my template adds Laminar as well as my own preferred setup for CSS (both are optional). I may add more optional features like TailwindCSS setup later.
Buildkit & Scalafmt Config
As the number of my open source projects keeps growing, I get more tired of copying various build-related things across them. Sbt plugins are ok to share Scala code, but I've recently wanted to add scalafmt configs to all my projects, and did not find an easy way to do that.
So, I published my standard scalafmt config in raquo/scalafmt-config β it captures the prevailing Laminar & Airstream formatting style, and should work well for both Scala 2 and Scala 3. Personally I think it's a better starting point than some of the defaults I've seen, but of course it's a matter of personal preference.
To distribute this shared config to my open source projects, I made raquo/buildkit β a tiny project that you add as a compile-time dependency to your build. At the moment, all it can do is download (and cache locally) any URL-versioned file into your project. You can then include
this now-local .scalafmt.shared.conf
file from your main .scalafmt.conf
file, and live happily thereafter.
The minimalistic design of this downloader is intended for low-overhead sharing of non-source files such as configs and assets. I don't want to spend the time publishing such things to sbt or npm β I'm happy to just use Github URLs and version tags for this.
Eventually I would like to explore this kind of low-overhead publishing further, because I feel that our community could benefit from reducing the barrier to sharing e.g. manually written Scala.js facade definitions, but for now, this is just a small tool, mostly for my own consumption, similarly to scala-dom-testutils.
Thank you
Note the large external contributions in this release β thank you all!
Laminar development is kindly supported by my sponsors, and I am very grateful to be able to work on all this.
DIAMOND sponsor:
GOLD sponsors: