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
EventPropTransformationtype and change them toEventProcessor, and fix imports if necessary - Find usages of old
mapToValuemethods and change them tomapToStrict
New API for controlled inputs (#79)
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.
Eliminate interference between overlapping cls modifiers (#71)
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
clsmodifiers) or externally (using native JSclassNameorclassListproperties), 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.
clsand other composite attributes no longer offersetorremovemethods. 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:
Styleclass from Scala DOM Types does not have a camelCased name param anymore. The kebab-cased param that used to be calledcssNameis now calledname, and the old camelCasednameparam was removed. So in the very unlikely case that you are accessing the style's oldnameproperty, 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.preventDefaulton 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
preventDefaultcheckbox clicks, you're on your own. The Laminar way is to use the new controlled inputs or the newsetAsCheckedoperator instead. - Migration: find usages like this in your code:
input(typ := "checkbox", onClick.preventDefault --> observer)and check if it still works as expected.
eventsmethod 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/indexOfEventListenermethods. Migration: If you want the plain JS versions, useDomApi. Otherwise, useEventListener#bindto add, andkillon the resultingDynamicSubscriptionto 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
DomVtype param fromProp[V, DomV]type alias in theLobject.- 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:
DomEventStreamclass moved from Laminar into Airstream.- Migration: just update the import if you're using it manually (you probably aren't).
API:
key <-- sourcereturns 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#applyis now final. You should be overriding thebindmethod instead.API:
child.intis now deprecated. Usechild.textto 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->EventProcessorNaming:
EventPropBinder->EventListenerNaming:
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
combineWithandwithCurrentValueOfoperators 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:
DomEventStreamwas moved from Laminar to Airstream
Migration notes:
- Import
DomEventStreamfromcom.raquo.airstream.webif 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
toStringand as a prefix when log-debugging.Check an observable's topoRank using
debugTopoRankmethod.
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
handleObserverErrorsparam that you can set tofalseto restore previous behaviour in cases where you rely on it. ObserverErroris a new subclass of AirstreamError, if you pattern match on that type, make sure to account for it.
Various new operators and observable factories
sampleandwithCurrentValueOfoperators are now available on Signals too, not just streams.EventStream.sequenceakacombineSeqto transformSeq[EventStream[A]] => EventStream[Seq[A]]Signal.sequenceakacombineSeqto transformSeq[Signal[A]] => Signal[Seq[A]]EventStream.mergeSeq, which is justmergethat accepts a Seq instead of varargs.
Various new Var, EventBus and Observer related helpers
EventBus now has
emitandemitTrymethods that are aliases foreventBus.writer.onNextandeventBus.writer.onTry. This complements Var'ssetandsetTrymethods.observer.contramapSomemakes anObserver[A]fromObserver[Option[A]]observer.contramapTrySimilarly,
myVar.someWriteris 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,toStreamIfSignalExample usage:
observable.toSignalIfStream(ifStream = _.startWith(0))toWeakSignalmethod is now available on Observable, not just EventStreamMoved
toSignal(initial)method from Observable to EventStream. Useobservable.toSignalIfStream(_.startWith(initial))as replacement.Removed
toStreamOrSignalChangesmethod. 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:
delayanddebounceoperators 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.updateandVar.updaternow 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
updatea 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.updatewhile a transaction is running (e.g. in an Observer callback), the transaction will only be executed after the current transaction has finished, so yourvar.updatecall will not throw.New behaviour is consistent β
var.updatewill not throw when trying to update a failed var, but will instead report the exception to Airstream's registeredunhandledcallback.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:
SwitchFutureStrategyis 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:
emitOnceparameter now defaults tofalseinEventStream.{fromSeq, fromValue, fromTry}methods.API: Provide custom
toStringimplementations forAirstreamErrorsubclassesAPI: We now encode
Observable#Selftype differently, using a type param on the newBaseObservabletrait. 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
Observabletrait in your own code as usual. You'll need to rewire your types to useBaseObservableif you used the oldObservable#Selftype 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 tokillOriginalSubscriptionand moved to the new subclass ofStrictSignalcalledOwnedSignal. 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
mapToValueoperator 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.argsFromPagereturns 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:
Routerconstructor 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
PatternArgsalias for URL DSL'sUlrMatchingtypeBuild: Update
url-dslto0.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! β€οΈ