Laminar v17.0.0 & Shoelace Web Components v0.1.0
This release has it all: new features, ergonomic improvements, and bug fixes, in both Laminar and Airstream.
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.0.0
- Airstream 17.0.0 (and Airstream 17.0.0-M3 migration helper – read below)
- Waypoint 8.0.0
- Laminar Shoelace Web Components 0.1.0
- ew 0.2.0
- Scala DOM Types 18.1.0
- Scala DOM Test Utils 18.0.1
- Laminar Demo updated
Ecosystem updates for v17:
- Laminext 0.17.0, Frontroute 0.19.0 (docs to be updated)
- Laminar SAP UI5 bindings 1.21.2
- Animus 6.0.1
Laminar Shoelace Web Components v0.1.0
Shoelace.js is a well made library of Web Components like Button, Dropdown, Dialog, etc. It features a pleasant modern design, both visually and technically, and is easily customizable to match your style guide.
To make type-safe and ergonomic Laminar bindings for these components, I created a semi-automatic generator that parses the type information from Shoelace's custom-elements.json
manifest, and outputs Laminar code. This gets us 70% of the way there, but requires a slew of manual additions and adjustments because custom-elements.json
does not contain all of the type information that we need.
And thus, Laminar Shoelace Bindings v0.1.0 is now available as the first published release, and lets you use almost all Shoelace components, albeit with some limitations and caveats. See the repo for more details. See the demo page for some of these components in action.
Aside from Shoelace, we already had Laminar bindings for SAP UI5 courtesy of Antoine. While UI5 offers more advanced components such as a date picker, it is much harder to visually customize than Shoelace.
In turn, Shoelace has recently raised half a million dollars on Kickstarter to create more advanced components, which they intend to offer on paid plans similar to Font Awesome, with the existing Shoelace components remaining free. With sustainable funding like this, I'm sure Shoelace will grow to be a great option for both enterprise users and hobbyists.
New Laminar Features
New Inserter Type
Previously, constructs that inserted dynamic nodes, like child <-- stream
or children <-- stream
, were called Inserter
-s. This type is now renamed to DynamicInserter
, and we also have a new Inserter
type that DynamicInserter
extends.
The new Inserter
type lets you write more efficient APIs that accept child nodes, regardless of whether they are static or dynamic. In Laminar, this is now used in onMountInsert
, as well as in Web Component slots.
The new Inserter type supports hooks – callbacks that happen when it makes changes to the DOM. So far, only onWillInsertNode
is supported, and we use it to set the slot
attributes of elements passed into Web Component slots. This is a low level API that will mostly help Laminar library and addon developers.
Technical notes
- Existing
Inserter
-s (child <-- ...
) et al. are now typed asDynamicInserter
(new name + no type param) - Introduced the concept of
StaticInserter
-s – they reserve a spot in the DOM just like the old dynamic inserters, but can only render static nodes, not streams of nodes. onMountInsert
now uses these static inserters to render static elements, which is more efficient for that use case.- If you want to make an API that accepts either static elements or dynamic inserters like
child <-- stream
(for example, for Web Component slots), you can use the newInserter
type. - Migration:
- Replace
Inserter.Base
andInserter[El]
types withDynamicInserter
orInserter
- If you have an
onMountInsert
block that returns either static elements likediv()
or dynamic inserters likechild <-- ...
based on some condition evaluated at mounting time, and you re-mount such a component several times – if you have this kind of logic, double-check that switching from static to dynamic (and the other way) still works for you. It should be fine, but I refactored it, so just to be sure.
- Replace
New Conditional Rendering Helpers
New conditional syntax for child, children, and text receivers
child(el) <-- signalOfBooleans
,child(el) := true
,child(el)(true)
- For non-observable booleans, you can also use the new
when(bool)(el1, el2, ...)
syntax – see below.
- For non-observable booleans, you can also use the new
children(el1, el2) <-- signalOfBooleans
, etc.- Note that these new receivers accept the same types of nodes as
child <-- observable
accepts inobservable
, so you can put elements and text nodes in them, but you can't directly nest otherchild <--
inserters at the moment – you would need to wrap them in an element. There's a ticket to address that.
New
when(bool)(modifiers: _*)
andwhenNot(bool)(modifiers: _*)
keywords, to reduce the need foremptyMod
/emptyNode
Replaced
cls.toggle("foo")
withcls("foo")
for more ergonomic syntaxcls("foo", "bar")
and all other such setters can now be used conditionally like so:cls("foo", "bar") := myBoolean
cls("foo", "bar")(myBoolean)
cls("foo", "bar") <-- myBooleanStream
- Eliminate
LockedCompositeKey
type, its functionality is now merged intoCompositeKeySetter
- Eliminate unused
itemsToRemove
property fromCompositeKeySetter
Render JS and Mutable Collections
Previously, Laminar's children <-- streamOfChildren
syntax required an Observable of an immutable.Seq
of elements (roughly speaking). Now you can provide observables of any collection.Seq
, scala.Array
, js.Array
, ew.JsArray
and ew.JsVector
. You may want to use JS collections or mutable collections for efficiency, when rendering very large and/or very frequently updated lists of items.
At the moment this mechanism isn't extensible to custom collection types. If you need that, please let me know.
When putting mutable collections in a Var, remember that mutating such an observable does not on its own cause the Var to emit an update – simply because the Var knows nothing of it. You need to call Var.set
or Var.update
to actually trigger the update, e.g. like this:
myVar.update { mutableArr =>
mutableArr.update(ix, newValue)
mutableArr
}
Also, note that the distinct
operator will filter out your updates based on mutation, because the reference does not change, and it remembers previous values by reference.
Read more about all this, including performance considerations, in the new doc section Rendering Mutable Collections
As part of this change, I simplified Laminar implicits to use the new RenderableSeq
typeclass. I believe that these changes are a net benefit, and they should also improve compiler error messages a bit (less of verbose "None of the overloaded alternatives of method ... match arguments" errors).
Migration: All Laminar syntax tests pass with the new implicits, however it's possible that some obscure use cases no longer compile if the compiler's ability to resolve new implicits does not exactly match the previous ones.
- If legitimate-looking Laminar code no longer compiles, please let me know. Things like: conversions of strings / numbers / etc. to text nodes, conversions of components (with
RenderableNode
to elements), all of the above but with collections, etc.
Significant Airstream Improvements
Goodbye flatMap
Users who are new to Airstream tend to over-rely on flatMap
, perhaps because they're thinking of Airstream observables as effect types. They are not, they're not even monads, strictly speaking, because of time and transactions. And that's ok, for our purposes.
As Airstream users (should) know, using flatMap
unnecessarily – i.e. in cases when other operators like combineWith
would suffice – creates observable graphs that can suffer from FRP glitches, and defeats Airstream's painstakingly fine-tuned Transaction mechanism that prevents such glitches. To be super clear, using flatMap
does not cause glitches on its own. It can only cause glitches when it's used unnecessarily, and even then, only under certain conditions. When flatMap
is used by true necessity, the observable graph is pretty much always structured in such a way that a glitch can't possibly happen (simplifying a bit here, but it really does work like that. Airstream docs about transactions, topological rank, and loopy vs flowy operators explain all that in more detail).
Unfortunately, with flatMap
being such a common operation on many data types, developers tend to reach for it before they learn about why it's a bad idea in Airstream, and many never read the entirety of the documentation – which does explain the undesirable characteristics of flatMap
in great detail. And so, they end up using flatMap
in a way that is unnecessary, and can thus cause FRP glitches.
Most of the problem with flatMap
is its very inviting / innocuous name, as well as Scala's for-comprehensions using it invisibly under the hood, resulting in developers using it on autopilot. And so, to improve the user experience, especially for non-experts, the method called flatMap
on Observables is now renamed into several variants, such as flatMapSwitch
, flatMapMerge
, and flatMapCustom
. It is thus no longer available via for-comprehensions.
Of the new operators, flatMapSwitch
is the "standard" one that matches the default behaviour of flatMap
.
Similar changes apply to the flatten
operator, of course.
See the rewritten Flattening Observables section of Airstream docs.
UPDATE: I would like to emphasize that using
flatMap
(nowflatMapSwitch
) to get async events is perfectly fine. To put it simply, the concept of glitches basically does not apply to observables that intentionally emit their events asynchronously. So you can safely useflatMapSwitch
to get e.g. network responses:
// This is fine.
val userS: Signal[User] = ???
val responseS: EventStream[Response] = userS.flatMapSwitch { user =>
FetchStream.get(s"/user/${user.id}")
}
Migration:
- First, see the compiler error caused by
flatMap
usage, and importFlattenStrategy.flatMapAllowed
as necessary, to make your code compile as-is. - Note:
flatMap
is only problematic on observables. Event props likeonClick
areEventProcessor
-s, notObservable
-s, soonClick.flatMap
does not cause a problem, and does not raise any errors. - When the rest of the migration is complete, go and actually check each of the deprecated
flatMap
usages to make sure that it's legitimate. If it can be replaced by operators likecombineWith
/withCurrentValueOf
/sample
, you should definitely do it. Some discussion here - Legitimate uses of
flatMap
with just a callback, without the secondstrategy
parameter, should be changed toflatMapSwitch
to get the same behaviour. Other legitimate usages should switch toflatMapMerge
orflatMapCustom
. Similarly for theflatten
operator. - As you address each usage, remove the import allowing legacy flatMaps, so that you know when you're done.
New Status Operators
When performing async operations using event streams, you sometimes need to know the current status of the operation – was it never triggered, was it triggered but is it still pending, or is it complete? (For error handling, refer to Airstream error handling).
Basic types:
sealed trait Status[+In, +Out] { /* ... */ }
case class Pending[+In](input: In) extends Status[In, Nothing] { /* ... */ }
case class Resolved[+In, +Out](input: In, output: Out, ix: Int) extends Status[In, Out] { /* ... */ }
Suppose we have a stream of network request arguments (requestS
), and we want to execute those requests, and show a "loading" indicator while the requests are in progress. This is how we could do it:
val requestS: EventStream[Request] = ???
type Response = String // but it could be something else
val responseS: EventStream[Status[Request, Response]] =
requestS.flatMapWithStatus { request =>
// returns EventStream[Response]
FetchStream.get(request.url, request.options)
}
val isLoadingS: EventStream[Boolean] = responseS.map(_.isPending)
val textS: EventStream[String] =
responseS.foldStatus(
resolved = _.toString,
pending = _ => "Loading..."
)
// Example usage from Laminar:
div(
child(img(src("spinner.gif"))) <-- isLoadingS,
text <-- textS
)
// Or, perhaps more realistically:
div(
child <-- responseS.splitStatus(
(resolved, _) => div("Response: " + resolved.output.toString),
(pending, _) => div(img(src("spinner.gif")), "Loading ...")
)
)
flatMapWithStatus
follows standard flatMap
/ flatMapSwitch
semantics. We also have similar operators for non-flatMap use cases like delayWithStatus
, which work similarly. There's also a bunch of new Status-specific helpers like foldStatus
and splitStatus
. For more details on all that, see the new documentation section Async Status Operators.
New Special Type Helpers
Airstream now has operators that help you work with observables of some popular "branched" types. For example, all of the following types have two possible "branches":
Boolean
hastrue
andfalse
Option[Foo]
hasSome[Foo]
andNone
Try[V]
hasSuccess[V]
andFailure
Either[L, R]
hasLeft[L]
andRight[R]
Status[In, Out]
hasPending[In]
andResolved[In, Out]
For each of those types, we have operators that help you map / collect / etc. over each of the branches, e.g.:
observableOfOption.mapSome(v => v2)
is equivalent toobservableOfOption.map(_.map(v => v2))
streamOfEither.collectRight
is equivalent tostream.collect { case Right(ev) => ev }
signalOfEither.foldEither(rightValue => C, leftValue => C)
signalOfBool.invert
– well, you get the idea
One important category of these new helpers are the specialized split
operators. They have the same semantics as the usual split
operator, except they split events by branch, instead of splitting them by e.g. _.id
, so for example, you can say:
val userTrySignal: Signal[Try[User]] = ???
div(
child <-- userTrySignal.splitTry(
success = (initialUser: User, userSignal: Signal[User]) =>
div("User name: ", text <-- userSignal.map(_.name)),
failure = (initialErr: Throwable, errSignal: Signal[Throwable]) =>
div("Something is wrong: ", text <-- errSignal.map(_.getMessage))
)
)
As you can see, splitTry
's callbacks are very similar to the standard split
callback, except the discriminator key was implicitly decided for you (_.isSuccess
), and you get separate callbacks that are more precisely typed for each case.
For more details, see the new Airstream doc sections:
Fix Stream Startup with Multiple Observers
The full details are available in Airstream/issue#111. This is quite a complex technical problem, so I'll try to focus on how you may be affected by the solution. So, suppose you have this code:
val container = dom.document.getElementById("app-container")
val stream = EventStream.fromValue(1)
render(
container,
div(
child.text <-- stream,
child.text <-- stream.map(_ * 10)
)
)
You would expect the mounted div to contain two text nodes: 1
and 10
. Obviously. But in fact, before v17, the div would only contain 1
, not 10
. In short, this happened because by the time the child.text <-- stream.map(_ * 10)
subscription was activated, the stream's 1
event has already finished propagating, so stream.map(_ * 10)
did not receive any events.
This happened whenever you've started a stream that emits an event on startup (EventStream.fromValue(1)
) by adding multiple subscribers at the same time: due to the bug, only the first subscriber would receive the event. Again, we're only talking about that one event that fires right when the stream is being started (i.e. when the div element is being mounted). Doing this is the entire purpose of the fromValue
stream, but most streams don't actually do this, and are unaffected.
After some struggles, I've fixed this bug, and the code above now works as expected. The trigger conditions for it are pretty niche, and I discovered the bug myself, with no reports of similar-sounding problems that I recall, so I'm guessing you are quite unlikely to be affected by it. In a couple of Airstream's own tests, the order of events in complex flatMap-s changed slightly due to this fix. The change did not go against any advertised contract, but could potentially break implicit assumptions in your code. Still, keep in mind that we use a lot of fromValue
/ fromSeq
streams in our test suite, have detailed timing checks, and still got very few observable changes in behaviour.
To help migration, I published Airstream version 17.0.0-M3
that contains this one fix, and nothing else from v17. It is binary compatible with Airstream v16.0.0, so you can add it to your project, and test with it to make sure that it does not break anything, before upgrading to 17.0.0. Note that this M3 version is Airstream only, Laminar has no such version. Use sbt evicted
to make sure that Airstream 17.0.0-M3
is actually selected.
Fix Transaction Stack Overflow
Airstream's old Transaction code was not stack safe. It is now.
I also added a (configurable) limit to how deep you can nest transactions (Transaction.maxDepth
). It defaults to 1000, and in practice you should never hit it unless you have an infinite loop of transactions (e.g. two Var-s updating each other with no filter). If you do hit the limit, it will prevent the execution of the offending transaction (thus breaking the loop), report a TransactionDepthExceeded
error into Airstream's unhandled errors, and proceed with the rest of your code.
Migration: no action needed unless you actually run into this error. You may want to check deeply nested or recursive code, but it's unlikely that you're hitting this limit yet aren't hitting the higher but still finite JS runtime stack depth with Airstream v16.
See Airstream#115 and Laminar#116.
Smaller Laminar Improvements
- New: Typings for Touch events (thanks, @felher!)
- New:
modSeq
andnodeSeq
- Small helpers for better type inference, e.g.
nodeSeq("text", span("element"))
returns a list of nodes, not a list ofjava.Object
likeList("text", span("element"))
does. So, basically copiednodeSeq
from Laminext. modSeq
works the same, but for modifiers.
- Small helpers for better type inference, e.g.
- New:
tapEach
andtapEachEvent
event processors, to complement the newtapEach
Airstream operator. - New:
filterNot
event processor, to complementfilter
- New:
filterByTarget
event processor, to filter values byevent.target
, e.g. if you want to filter out clicks on all child<a>
links - New:
<tag>.jsTagName
to help with the above, becauseelement.ref.tagName
is uppercased for HTML elements, but not for SVG elements, e.g.a.jsTagName == a().ref.tagName == "A"
. - New:
flatMapWithStatus
event processors to match new Airstream operator - New
text <-- ...
alias tochild.text <-- ...
- New:
apply
alias forcompose
event processor- You can now say e.g.
onClick(_.debounce(100)) --> ...
- You can now say e.g.
- New: Better support for Web Components
- Controlled inputs work with Web Components now
- CustomHtmlTag and Slot (improved API and moved into Laminar from Laminar-Shoelace)
- Fix: Bring back checks against conflicting value controller binders.
- You can't have both
controlled(value <-- ...)
and un-controlledvalue <-- ...
binders on the same element, it does not make sense. Similarly for thechecked
property. - Migration: if you are affected, you'll start getting an exception now. Your code already had a hidden bug in it, now it will become more obvious.
- You can't have both
Smaller Airstream Improvements
- New:
EventStream.fromPublisher
creates an AirstreamEventStream
from Java'sFlow.Publisher
(thanks, @armanbilge!).- This lets you consume FS2, Monix, or some other libraries' streams in Laminar
- New: tapEach operator
- Naming:
eventBus.stream
alias toeventBus.events
, for consistency with Var'ssignal
. - Naming:
signal.changes(op)
alias tosignal.composeChanges(op)
.signal.changes
(with no parens) remains the same. - Misc: Better
displayName
-s- Shorten default
displayName
-s (com.raquo.airstream.eventbus.EventBus@<hashCode>
->EventBus@<hashCode>
etc.) for allNamed
types, including observables, event buses, vars, etc. - Use pretty default names for var signals and eventbus streams (e.g.
Var@<hashCode>.signal
) (this also affectstoString
) - Migration: your tests might break if they rely on previous default
displayName
-s
- Shorten default
- Fix: The per-item signals provided by the split operator now re-sync their values to the parent signal when restarted – this makes their behaviour consistent with other signals since v15.
- Migration: You are unlikely to be relying on the current behaviour, since it is quite undesirable. See Airstream#120 for an example.
- Fix:
split
operator's memoization works forLazyList
now - Fix: More robust error reporting
- Handle exceptions that happen while printing exceptions
- Yes, that can happen, and yes, that happened.
- Airstream's unhandled-errors now include error class name in error messages, not just its message
split
operator's duplicate key warnings go to unhandled-errors now- Migration: nothing changes, unless you added / removed Airstream unhandled error callbacks
Airstream.sendUnhandledError
is now public
- Handle exceptions that happen while printing exceptions
Other Goodies
Latest Laminar includes Scala DOM Types v18.1.0 – see its Changelog
ew v0.2.0 now includes JsVector, which is just
JsArray
in an immutable trench coat.I also added more methods to the JsArray type, and fixed a bunch of errors in
ew
, most notably theJsArray.from
method, which was simply casting the providedjs.Array
instead of shallow-copying it (the latter is how the realjs.Array.from
behaves, and what one would expect).
Other Laminar Breaking Changes
Migration should be obvious where not specified.
- Creating a custom
RenderableNode
now only requires a single parameter:- e.g.
RenderableNode(_.node)
instead ofRenderableNode(_.node, _.map(_.node), ...)
- e.g.
- Drop support for Scala 2.12
- The RFC has been up for more than a year, and nobody has spoken in favor of keeping support.
- Internal structure refactor:
- Rename
trait Airstream
->AirstreamAliases
- Move
trait Implicits
to api package - Extract some code from
Laminar
trait intoMountHooks
andStyleUnitsApi
- Move all the inserters,
InsertContext
andCollectionCommand
to newinserters
package - Eliminate
FocusBinder
object - usefocus
directly - Remove unused
extraNodes
field fromInsertContext
- Some children related internal types now accept
laminar.Seq
instead ofimmutable.Seq
- Rename
- Naming:
ValueController
->InputController
Thank you
First of all, special thanks to the Scala.js core team for continued improvements of our already magnificent foundation! Building things on Scala.js has been consistently pleasant and productive every year since I've started using it. Cheers for Sébastien and Tobias!
Laminar development is kindly supported by my sponsors, and I am very grateful for being able to work on all this.
DIAMOND sponsor:
GOLD sponsors: