Laminar v0.12.0, Laminext, Material UI & more!
Laminar v0.12.0 and Airstream v0.12.0 are out! π
This is a big release packed with new features and ergonomics improvements. Ctrl+F for "migration" in this post for directions on breaking changes. Don't worry, the "breaking" stuff is minor and mostly concerns previous misbehaviours in edge cases. Existing users, make sure to read the new / updated parts of the docs as linked below.
Laminar is a native Scala.js library for building web application interfaces. Learn more in one short page or one long video.
-1. Hot Patches
Use Laminar 0.12.1 over 0.12.0. I fixed a minor logging issue.
The date of this 0.12.0 post is incorrect. It was published on Feb 26, 2021, not Feb 3. I am not going to change it since that would break the URL.
0. News
Laminext π
Announcing the first release of laminext, Iurii's collection of components, utilities and helpers for Laminar and Airstream; including a websockets client, form validation utils, and a set of Laminar components designed with Tailwind CSS.
Laminar Web Components π
Uosis published the first version of Material UI Web Components for Laminar. Welcome news for those looking for prebuilt Laminar components!
Kit's great talk on Scala.js, Laminar & other goodies π
Stockholm Syndrome Escape Velocity β Kit Langton, Functional Scala 2020
Tawasal π
Check out our new GOLD sponsor, Tawasal β a secure multi-purpose messenger and superapp, offering free voice, text, video conferencing and lifestyle services.
And more π
Iurii rebranded his laminar-router as frontroute and added more docs and features.
Other com.raquo releases: Waypoint 0.3.0, Scala DOM Types 0.14.0
1. Laminar Changes
New EventProcessor / EventPropTransformation operators
The concept of EventPropTransformation was renamed to EventProcessor.
EventProcessor got several new operators, most importantly mapToValue
and mapToChecked
, which significantly reduce boilerplate when trying to get the input's value or the checkbox's checked status.
// BEFORE
input(inContext { thisNode => onInput.mapTo(thisNode.ref.value) --> textObserver })
// AFTER
input(onInput.mapToValue --> textObserver)
As you might know, pre-v0.12.0 Laminar already had a mapToValue[V](v: V)
operator which acted like a strict version of mapTo[V](v: => V)
. This operator was renamed to mapToStrict
in this version.
Other notable new operators are setAsValue
and setAsChecked
. They are only available on event processors that emit strings and booleans respectively, and set the value prop (or the checked prop) to the emitted value. This is an alternative to filtering / processing user inputs without using controlled inputs.
Migration notes:
- Find usages of
EventPropTransformation
type and change them toEventProcessor
, and fix imports if necessary - Find usages of old
mapToValue
methods and change them tomapToStrict
#79)
New API for controlled inputs (Previously, you could implement controlled inputs in Laminar like this:
val inputState = Var("")
input(
value <-- inputState.signal,
inContext { thisNode => onInput.mapTo(thisNode.ref.value) --> inputState },
// other mods, if any
)
This usually worked well enough, and continues to work exactly as before in 0.12.0. However, this approach has a fundamental problem: the input's value isn't actually locked to the output of inputState.signal
, it's simply updated every time inputState.signal
emits. #79 explains the technicalities of how the signal's value and the input value could diverge in previous versions of Laminar.
We now offer a new way to do controlled inputs: simply wrap your value updater and input listener into controlled
, e.g.:
input(
controlled(
value <-- inputState.signal,
inContext { thisNode => onInput.mapTo(thisNode.ref.value) --> inputState }
),
// ... other mods, if any
)
But actually, due to unrelated improvements in 0.12.0 (see below), this can be shortened to:
input(
controlled(
value <-- inputState,
onInput.mapToValue --> inputState
),
// ... other mods, if any
)
This works similarly with all input types that can be controlled including checkboxes and radio buttons (using checked
and onClick
) and <select>
elements (using value
and onChange
).
The new controlled input API ensures that the input's value
stays in sync with the observable it's listening to, so it behaves slightly differently, but 95% of the time, this new way is a drop-in replacement for the old way of doing things (which is still supported).
Make sure to read the new Controlled Inputs section of the docs as there are important caveats to this new API.
New way to stream events: composeEvents
With the usual div(onClick.preventDefault --> eventObserver)
syntax you can only apply a limited set of EventProcessor operators like map
, filter
, preventDefault
to each event (and also Observer operators like contramap
and delay
on the right hand side), but sometimes you want full palette of stream operators. Previously you would need to use inContext
to achieve this:
div(
inContext { _.events(onClick).throttle(0) --> eventObserver }
)
Now you can skip the curly braces and just use composeEvents
:
div(
composeEvents(onClick)(_.throttle(0)) --> eventObserver
)
This approach is inspired by thisEvents
from Laminext by the way.
composeEvents
is particularly nice when you want to apply both EventProcessor and stream operators, for example:
val $allowClick: Signal[Boolean] = ???
a(
composeEvents(onClick.preventDefault) {
_.withCurrentValueOf($allowClick)
.collect { case (ev, true) => ev }
} --> eventObserver
)
Thanks to mapToValue
/ mapToChecked
and composeEvents
, you will barely ever need inContext
now.
#71)
Eliminate interference between overlapping cls modifiers (In Laminar, you can attach several cls
modifiers to the same element, for example:
input(
cls := "TextInput",
cls <-- streamOfClasses
)
This generally works just fine: streamOfClasses
adds class names that it emits, replacing any class names it emitted previously, while the "TextInput" class name remains on the input element throughout all this.
However, prior to this release, users needed to make sure that streamOfClasses
does not emit a class name added by another cls
modifier, such as "TextInput" in this case. If this happened, and then streamOfClasses
emitted another event without that class name, Laminar would remove the "TextInput" class name, whereas the user might expect it to remain on the input element indefinitely, as indicated by cls := "TextInput"
.
This interference was documented, but was still undesirable.
Starting with 0.12.0, Laminar behaves as expected in this case. Elements now keep track of which cls
modifiers want to keep which class names, and only remove a class name if none of the modifiers want it anymore, so in the example above the "TextInput" class name would indeed never be removed from the input element even if streamOfClasses
is not disciplined about what it emits.
This applies to other composite attributes such as rel
just as well.
See the updated cls section of Laminar docs for details.
Migration notes:
A given class name on a given element must be managed either by Laminar (via
cls
modifiers) or externally (using native JSclassName
orclassList
properties), but not both. Doing so will cause unexpected behaviour. Same goes for other composite attributes.- Note: you can still manage class names on the same element using both Laminar and native JS methods as long as those two subsets of class names don't overlap.
cls
and other composite attributes no longer offerset
orremove
methods. You'll need to use the<--
method to achieve that.
Easier way to build custom DOM events, attrs, props, etc.
Laminar gets its DOM type definitions from Scala DOM Types: things like div
, onClick
, backgroundColor
are all defined there.
So when you find some DOM event or CSS prop missing, it's a good idea to add it to Scala DOM Types. But you could also always create instances of ReactiveProp
/ ReactiveEventProp
/ etc. for missing keys manually. We now provide easier syntax for this, which comes especially handy to reduce the boilerplate of defining Laminar interfaces to third party web components:
// BEFORE:
val superValue: ReactiveProp[String, String] = new ReactiveProp("superValue", StringAsIsCodec)
val onWhatever: ReactiveEventProp[dom.WhateverEvent] = new ReactiveEventProp("whatever")
val innerColor: ReactiveStyle[String] = new ReactiveStyle(new generic.Style[String]("innerColor", "inner-color"))
// AFTER:
val superValue: ReactiveProp[String, String] = customProp("superValue", StringAsIsCodec)
val onWhatever: ReactiveEventProp[dom.WhateverEvent] = customEventProp("whatever")
val innerColor: ReactiveStyle[String] = customStyle("inner-color")
Migration notes β Style class
- If you have defined custom style props in your project, this is relevant to you:
Style
class from Scala DOM Types does not have a camelCased name param anymore. The kebab-cased param that used to be calledcssName
is now calledname
, and the old camelCasedname
param was removed. So in the very unlikely case that you are accessing the style's oldname
property, you'll need to provide your own replacement for that. But the camelCased version is not actually needed for anything in the DOM, so make sure that you shouldn't be using the kebab-case version instead. scala-dom-types/#67.
Reduce API surface
Laminar doesn't try to save you from yourself when you use
onClick.preventDefault
on checkboxes anymore. Previously if you registered a click listener on a checkbox and used the EventPropTransformation preventDefault operator on it, Laminar would call your observer asynchronously to make sure that the browser resets the checkbox state before your observer is run.- See details of the issue here
- Basically, if you want to
preventDefault
checkbox clicks, you're on your own. The Laminar way is to use the new controlled inputs or the newsetAsChecked
operator instead. - Migration: find usages like this in your code:
input(typ := "checkbox", onClick.preventDefault --> observer)
and check if it still works as expected.
events
method only accepts a single param now, an EventProcessor.- Migration: use EventProcessor operators to achieve what the optional params would, e.g.
events(onClick.useCapture)
instead ofevents(onClick, useCapture = true)
.
- Migration: use EventProcessor operators to achieve what the optional params would, e.g.
Removed
ReactiveElement.addEventListener
/removeEventListener
/indexOfEventListener
methods. Migration: If you want the plain JS versions, useDomApi
. Otherwise, useEventListener#bind
to add, andkill
on the resultingDynamicSubscription
to remove the listener.
Misc Laminar changes
You might need a migration for some of these. Hopefully the steps are obvious.
New:
node.amendThis(thisNode => mod)
method that works similar toamend
, but provides a reference to the element it's being called on.API: Removed
DomV
type param fromProp[V, DomV]
type alias in theL
object.- Migration: Remove that type param from your type signatures, you're most probably not using it. If you are, use the underlying
ReactiveProp[V, DomV]
type.
- Migration: Remove that type param from your type signatures, you're most probably not using it. If you are, use the underlying
Fix: Don't lowercase Web Component event names (#77)
Fix: Laminar can now render the app into shadow DOM (#84)
API: Reduce the number of
<--
and-->
methods using new Source & Sink types (this expands the functionality, old usages are ok).API: Replace deprecated
maybeEventListeners: Option[List[...]]
witheventListeners: List[...]
API:
DomEventStream
class moved from Laminar into Airstream.- Migration: just update the import if you're using it manually (you probably aren't).
API:
key <-- source
returns a more specific type now, KeyUpdater. It's still a Binder, so not a breaking change. Might be useful for better type safety.API:
Binder#apply
is now final. You should be overriding thebind
method instead.API:
child.int
is now deprecated. Usechild.text
to render observables of String, Boolean, Int, Double, or any other type for which you define an implicit conversion toTextNode
.Misc: Scala DOM Types upgraded from 0.11.0 to 0.14.0. See changelog.
Naming:
EventPropTransformation
->EventProcessor
Naming:
EventPropBinder
->EventListener
Naming:
mapToValue
->mapToStrict
2. Airstream Changes
Airstream is the reactive core of Laminar
New operators to combine / sample / withCurrentValueOf N observables
Airstream always had a streamOfA.combineWith(streamOfB)
method that produced a stream of (A, B)
tuples. Now, you can combine more than two observables at the same time, e.g. streamOfA.combineWith(streamOfB, streamOfC)
results in a stream of (A, B, C)
.
Moreover, whereas streamOfA.combineWith(streamOfB).combineWith(streamOfC)
previously resulted in a stream of ((A, B), C)
, in the new version it results in a stream of (A, B, C)
, which is much more useful.
The operators sample
and withCurrentValueOf
received a similar upgrade, allowing you to peek at more than one signal at a time.
If you want to combine observable values into something more specific than a tuple, just use the new combineWithFn
operator instead of combineWith
: streamOfX.combineWithFn(streamOfY)(Point)
.
There's more. Observables of (A, B)
used to have a map2((a, b) => ???)
operator available for convenience, but now we have similar mapN
and also filterN
operators that work for observables of tuples of higher arities too.
One last thing. Thanks to new Source & Sink types, you can now pass Vars directly into methods that used to require a Signal, for example you can now say signal.combineWith(myVar)
instead of having to spell out signal.combineWith(myVar.signal)
.
Huge thanks to Iurii for his contribution of TupleN-capable code generators and his tuplez.Composition utility that we now use to flatten tuples in chained combineWith
calls.
See the new N-arity Operators section of Airstream docs for details.
Migration notes:
- Chained
combineWith
andwithCurrentValueOf
operators result in a different type now! Check all usages. - Rename
map2
->mapN
New web platform features
New:
AjaxEventStream
. A simple helper to manage Ajax requests idiomatically in Airstream. Thanks to Ajay for working on it!See the new Ajax section of Airstream docs for usage details.
Move:
DomEventStream
was moved from Laminar to Airstream
Migration notes:
- Import
DomEventStream
fromcom.raquo.airstream.web
if you use that type
New ways to create callback-driven streams
Up until this point, EventBus
was the go-to mechanism for creating streams fed by callbacks / observers. Now we have a few alternative ways that might be more visually familiar to users of React hooks. Rest assured however that there is exactly zero magic going on (as usual), the similarity with React is only cosmetic. For example, here is the full implementation of EventStream.withCallback
:
def withCallback[A]: (EventStream[A], A => Unit) = {
val bus = new EventBus[A]
(bus.events, bus.writer.onNext)
}
And here is how you might want to use these:
val (stream1, callback) = EventStream.withCallback[String]
val (stream2, jsCallback) = EventStream.withJsCallback[String]
val (stream3, observer) = EventStream.withObserver[String]
callback("1") // Make `stream1` emit `1`. Similarly for other pairs.
div(
onMountCallback(_ => callback("Mounted!")),
ReactJsButtonComponent.Props(
caption = "Click 2",
onClick = jsCallback // js.Function1[String, Unit]
).render,
button("Click 3", onClick.mapTo("Clicked!") --> observer),
div("stream1: ", child.text <-- stream1),
div("stream2: ", child.text <-- stream2),
div("stream3: ", child.text <-- stream3)
)
See the relevant section of Airstream docs for more details.
New way to create custom sources of events
If you want to bring events into Airstream from a third party library that requires initialization and the subsequent cleanup of the event source, such as ZIO
or d3
or even the DOM itself, EventStream.withCallback
and EventBus
are not going to cut it as they do not provide a way to specify onStart / onStop callbacks.
So, previously you had to subclass EventStream
or Signal
to achieve this. That's not actually hard, but it's intimidating, and that way the full palette of Airstream internals is available to you for abuse.
Now, you can use the new CustomSource
functionality which provides the hooks necessary to achieve this, and nothing more. In fact, DomEventStream
is implemented this way:
def apply[Ev <: dom.Event](
eventTarget: dom.EventTarget,
eventKey: String,
useCapture: Boolean = false
): EventStream[Ev] = {
CustomStreamSource[Ev]( (fireValue, fireError, getStartIndex, getIsStarted) => {
val eventHandler: js.Function1[Ev, Unit] = fireValue
CustomSource.Config(
onStart = () => {
eventTarget.addEventListener(eventKey, eventHandler, useCapture)
},
onStop = () => {
eventTarget.removeEventListener(eventKey, eventHandler, useCapture)
}
)
})
}
See the new Custom Event Sources section of Airstream docs for details.
Derived Vars
Given A => B
, you can map Signal[A]
to Signal[B]
, and given B => A
, you can contramap Observer[A]
to Observer[B]
.
But Var is just a bundle of Signal[A]
and Observer[A]
. So given both A => B
and B => A
, why can't you varA.zoom(aToB)(bToA)
to get a Var[B]
?
Well, because the Signal
in Var
is actually a StrictSignal
, that is, it's not lazy. Only lazy signals can be mapped like this. Strict signals can only be mapped to a lazy signal unless you provide an owner. This limitation is for memory safety. It would be all too easy to cause memory leaks otherwise. Airstream actually used to have a strict State[A]
type that did this a long time ago, but we got rid of it for this reason.
Well ok, couldn't we just zoom into a Var having A => B
, B => A
, and an owner
? Why yes, as of 0.12.0, we can!
implicit val owner: Owner // usually you get this from ctx in Laminar's onMount* methods
val oneBasedIndex = Var(1)
val zeroBasedIndex = oneBasedIndex.zoom(_ - 1)(_ + 1)(owner)
oneBasedIndex.set(10)
oneBasedIndex.now() // 10
zeroBasedIndex.now() // 9
zeroBasedIndex.set(20)
oneBasedIndex.now() // 20
zeroBasedIndex.now() // 19
This can be useful to reduce boilerplate when implementing forms. You could zoom Var[MyFormState]
into vars for every field and pass them down to the corresponding input components.
See the new Derived Vars section of Airstream docs for details.
New debugging functionality
Debugging featues were significantly improved in Airstream. You can now:
Debug not just events, but also errors flowing through observables, as well as observable starts, stops, and the evaluation of signals' initial value.
Debug observers similarly to observables.
Name your observables and observers. These names will be used by
toString
and as a prefix when log-debugging.Check an observable's topoRank using
debugTopoRank
method.
See the updated Debugging section of Airstream docs for details.
Migration notes:
debug*
methods were renamed and some accept different params now, see docs for details.debug*
methods are now available via implicit classes rather than directly on the Observable type. This just FYI. Usage syntax is the same, and they are available automatically, no need to import anything.
Observers now handle their own errors by default
Previously, an exception thrown inside an Observer's user-provided onNext
callback resulted in Airstream reporting an unhandled error. Now, if this happens, the observer's own onError
callback will be invoked first to try and handle the error. If such a callback was not provided by the user, the parent observer's onError
callback will be invoked, as usual.
Errors caused by observers are wrapped into ObserverError
.
This new behaviour only affects observers created using Observer factories like Observer.apply
and Observer.fromTry
. If you manually extend the Observer
trait, you're on your own.
See the updated Handling Errors Using Observers section of Airstream docs.
Migration notes:
- This changes observer error propagation behaviour. Errors will now propagate up the chain of observers instead of being reported as unhandled. They might still end up being reported as unhandled if the last observer in the chain does not handle the error, but that's not a given, and the timing will be different.
- Observer factories have a new
handleObserverErrors
param that you can set tofalse
to restore previous behaviour in cases where you rely on it. ObserverError
is a new subclass of AirstreamError, if you pattern match on that type, make sure to account for it.
Various new operators and observable factories
sample
andwithCurrentValueOf
operators are now available on Signals too, not just streams.EventStream.sequence
akacombineSeq
to transformSeq[EventStream[A]] => EventStream[Seq[A]]
Signal.sequence
akacombineSeq
to transformSeq[Signal[A]] => Signal[Seq[A]]
EventStream.mergeSeq
, which is justmerge
that accepts a Seq instead of varargs.
Various new Var, EventBus and Observer related helpers
EventBus now has
emit
andemitTry
methods that are aliases foreventBus.writer.onNext
andeventBus.writer.onTry
. This complements Var'sset
andsetTry
methods.observer.contramapSome
makes anObserver[A]
fromObserver[Option[A]]
observer.contramapTry
Similarly,
myVar.someWriter
is anObserver[A]
forVar[Option[A]]
New method
observer.delay(ms = 100)
creates an observer that calls the original observer after a delay
Observable conversion methods moved / renamed
New methods on Observable:
toSignalIfStream
,toStreamIfSignal
Example usage:
observable.toSignalIfStream(ifStream = _.startWith(0))
toWeakSignal
method is now available on Observable, not just EventStreamMoved
toSignal(initial)
method from Observable to EventStream. Useobservable.toSignalIfStream(_.startWith(initial))
as replacement.Removed
toStreamOrSignalChanges
method. UsetoStreamIfSignal(_.changes)
instead.
Migration notes:
Change usages of observable.toSignal
and toStreamOrSignalChanges
as noted above. Note that toSignal
is still available on streams.
Assorted fixes and improved behaviour
Fix: Bug in JsPriorityQueue implementation that produced glitches in complex cases.
Specifically, when two or more observables were pending in the same transaction at the same time, if one of them actually synchronously depended on the other, it could fire ahead of the observable that it depended on, causing a glitch. Whether this actually happened also depended on the order of (internal) observers in the observable graph, so you could avoid this bug just by luck. This bug could affect nested combineWith or delaySync observables, resulting in extraneous events (glitches) in case of combineWith, or observables firing in the wrong order (in case of delaySync).
See gitter for the original bug report.
Unless you were facing unexplained glitches, it seems pretty unlikely that this bugfix would affect you, but watch out for changes in emitted events if you have deeply nested combined or delaySync observables.
Fix:
delay
anddebounce
operators now clear the pending events after being stopped.Previously, if you stopped the delayed stream and then immediately re-started it, delayed events scheduled before the stream was stopped would fire after it was re-started if their delays did not complete while the stream was stopped. This could lead to unexpected behaviour. No more.
API:
Var.update
andVar.updater
now report exceptions as unhandled instead of throwing inside a transactionThrowing inside a transaction has unpredictable results, because you do not know when the transaction will actually execute. If you try to
update
a failed var outside a transaction, e.g. in your app's main method, the transaction will run immediately and will throw. But if you callmyVar.update
while a transaction is running (e.g. in an Observer callback), the transaction will only be executed after the current transaction has finished, so yourvar.update
call will not throw.New behaviour is consistent β
var.update
will not throw when trying to update a failed var, but will instead report the exception to Airstream's registeredunhandled
callback.API: Introduced new types: Sources & Sinks
Fix: Avoid redundant re-starting in flattened switch observables. Fixes #55.
Fix: Make throttle operator behave in a standard way. Fixes #66.
API:
SwitchFutureStrategy
is now the implicit default for flatteningObservable[Future[A]]
Previously you needed to choose a flattening strategy explicitly for this type. New default is consistent with other types' implicits.
API:
emitOnce
parameter now defaults tofalse
inEventStream.{fromSeq, fromValue, fromTry}
methods.API: Provide custom
toString
implementations forAirstreamError
subclassesAPI: We now encode
Observable#Self
type differently, using a type param on the newBaseObservable
trait. The previous encoding as a type member field was (rarely but annoyingly) causing issues with type inference and IntelliJ Scala plugin.- Migration: This shouldn't affect end users: you should continue using the
Observable
trait in your own code as usual. You'll need to rewire your types to useBaseObservable
if you used the oldObservable#Self
type member.
- Migration: This shouldn't affect end users: you should continue using the
Docs: See the new Var Transaction Delay section of Airstream docs. No change here, just new docs.
Migration notes:
- All of these changes are potentially breaking to various degrees. Hopefully the required mitigation for each is clear.
Moved packages and renamed classes
Most Airstream classes were moved around in this release. They retained their names, but were moved into a different package, now grouped by functionality rather than type of observable.
Migration notes:
- Most Airstream types that you're using are imported via aliases defined in
com.raquo.laminar.api.L
, so you won't need to do anything for those. - For other Airstream types you'll need to change your imports. Since the names of the classes are the same, your IDE should be able to help you out. Otherwise just use the code search on the github repo to find the new locations, or read the relevant parts of the commit log.
StrictSignal
's kill method was renamed tokillOriginalSubscription
and moved to the new subclass ofStrictSignal
calledOwnedSignal
. And so,signal.observe(owner)
now returns an OwnedSignal. Unless you actually used the kill method, you shouldn't need to change anything.
Other breaking changes
Migration notes:
- Renamed various params for time-related operators and classes. Changes should be obvious from compilation errors, but here's the diff
- Renamed
mapToValue
operator tomapToStrict
3. Waypoint Changes
Waypoint is an optional URL router for Laminar.
New: Routes can match types partially now (thanks, @pbuszka!)
- New route creation methods with PF suffix
- Migration:
route.argsFromPage
returns an Option now
New: Customizable error handling
- You can now specify fallbacks for URLs and page states that fail to match
- You can now force-render a page without changing URL (useful for rendering full page error screens)
- Migration:
Router
constructor arguments have changed and are now spread across two argument lists, and some arguments have defaults now
New: ContextRouteBuilder (thanks, @pbuszka!)
- Provides a convenient way to encode URL params like "lang" or "version" that are shared among a set of pages & routes
API: Use
PatternArgs
alias for URL DSL'sUlrMatching
typeBuild: Update
url-dsl
to0.3.2
- New features: matching URL #fragments. See also: URL-DSL migration notes
- Note that Waypoint takes care of most URL-DSL imports for you.
- New features: matching URL #fragments. See also: URL-DSL migration notes
Thank You
Huge thanks to the contributors to this release:
- Iurii Malchenko: N-arity combine operators, code generators & tuplez
- Ajay Chandran: AjaxEventStream
- Piotr Buszka: Waypoint partial and context routes
- Binh Nguyen: Build cleanup
- AndrΓ© L. F. Pinto: Fixes in docs
Special thanks to Kit Langton for the amazing Scala.js & Laminar talk!
Laminar & Airstream development is sponsored by people like you.
GOLD sponsors supporting this release:

Thank you for supporting me! β€οΈ