Laminar v15.0.0 Mega Release
Huge release with better Airstream semantics and many new features.
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.
News
Feast your eyes on the largest Laminar & Airstream release to date. It brings many new features, and improves ergonomics, performance, and correctness. After more than a year of development, and more than two months of milestone release testing, and more testing, more documenting, here we are now, with this mountain behind us.
With this release, Laminar's version is jumping an order of magnitude from 0.14.5 to 15.0.0 to reflect the level of stability and maturity of the project. Why not 1.0.0? Because it would give users the wrong idea – if it took 6 years to get to "1.0.0", will it take another six years before we see 2.0.0? I hope not, because Laminar isn't "done", there is still much work ahead. This new version is great, but it isn't special, it's simply the latest of many stable releases over the years, and the new version number reflects that.
⚠️ Please use Laminar version 15.0.1, it fixes a regression in
cls.toggle
.
For current Laminar users – the migration for this release will likely be more involved than usual. This is not due to the size of the release – most of the breaking changes and renamings are easy to address – but because we have changed one aspect of Airstream signals behaviour, so the migration will require some manual testing / review. On the plus side, I have – as always – meticulously documented every change in this release, along with the necessary migration steps. Once it's done, it'll be worth it.
I typically include Laminar ecosystem news on top of every release post, but this would be completely lost in such a big post, so I will post that some time separately.
New GOLD Sponsor
HeartAI is a data and analytics platform for digital health and clinical care. The HeartAI team are based in South Australia and have been working with SA Health and the public health system to modernise digital health. Their capabilities include real-time data integration, modern web applications, and operational artificial intelligence. Supporting the SA Virtual Care Service, they have deployed a web application that is built with Laminar, Scala.js, and also a novel implementation of the popular D3.js visualisation library. The Laminar and Airstream libraries have helped HeartAI create dynamic and scalable applications. See the demo application and further information in the application architecture documentation.
Sponsors
Laminar development (and documentation and testing and community support) is a lot of work, and sponsorship revenue makes a huge difference in my ability to do all this. A sincere thank you to all of my sponsors for making this corner of open source more sustainable, and an open invitation to anyone making good use of Laminar to join this fine club of supporters.
GOLD sponsors supporting Laminar:
Table of Contents
- New Documentation Sections
- New Laminar Features
- New Airstream Semantics
- New Airstream Features
- Changes to Airstream
scala.Future
integration - Minor Breaking Changes
- Changes Only Relevant To Laminar & Airstream Extensions
- New Minor Conveniences
- Other Minor Changes
- User-facing Renamings
New Documentation Sections
In addition to the very detailed release notes below, existing users of Laminar should read these new documentation sections:
- Laminar Anti-patterns – learn what not to do
- Airstream – Restarting Observables
- Laminar – Approaches to CSS
- Laminar – Browser Compatibility
New Laminar Features
Improved Performance
I spent some time profiling Laminar & Airstream, and implemented a few fixes to optimize the choice and implementation of data structures, and reduce / delay allocations. Laminar was already plenty fast, but perhaps you will notice the improvement on very resource constrained mobile devices.
I did find and fix one notable performance issue when rendering very large lists of children (thousands or tens of thousands of items). This was actually done in 0.14.2, but I didn't have time to write a blog post just for that.
Laminar now uses my new library ew to get consistently fast implementations of methods like indexOf
and forEach
on JS collection types. Check it out if you are interested in JS collections performance.
Lastly, make sure you understand the performance tradeoffs that the removal of automatic ==
checks from Signals entails. More on that below, in the section about Airstream semantics.
flatMap
and compose
for DOM events
Typically, you subscribe to DOM events without explicitly creating any streams. This is simple and convenient, but it lacks the full palette of observables operators. We already had two ways to access those fancy operators in Laminar:
div(
onClick.delay(1) --> observer, // does not compile
inContext(_.events(onClick).delay(1) --> observer),
composeEvents(onClick)(_.delay(1)) --> observer
)
This composeEvents
method always rubbed me the wrong way because it's not discoverable – you can't find it via autocomplete after typing onClick.
. Something prevented me from doing this earlier, but now I've realized that I can offer equivalent functionality as an onClick.compose
method which works just like the observables' native compose
method:
div(
onClick.compose(_.delay(1)) --> observer,
onClick
.preventDefault
.map(getFoo)
.compose(_.filter(isGoodFoo).startWith(initialFoo)) --> observer,
)
I've also added a new flatMap
method which is useful when you want to create a new observable on every DOM event. For example:
def makeAjaxRequest(): EventStream[Response] = ???
input(
onInput
.mapToValue
.flatMap(txt => makeAjaxRequest(txt)) --> observer
)
If you use this new flatMap
method in IntelliJ IDEA in a Scala 2 codebase, you'll be annoyed to find that it causes the IDE to incorrectly report a false type error. As a workaround, I added more specialized flatMapStream
and flatMapSignal
methods which use simpler types, and don't trigger the false error in the IDE.
Between flatMap
and compose
, obtaining observables' functionality from DOM events is much more natural now, so composeEvents
is now deprecated. Migration is trivial: rewrite composeEvents(a)(b)
to a.compose(b)
.
Easier Integration With Third Party DOM Elements
Previously you could only use low level methods to inject foreign elements into the Laminar element tree. Now if some JS library gives you a DOM element, you can wrap it in Laminar goodness and use it like any other Laminar element, including adding event listeners and dynamically updating its properties:
def getThirdPartyMapWidget(): dom.html.Element = ???
div(
foreignHtmlElement(getThirdPartyMapWidget()),
// And this is how you add modifiers in the same breath:
foreignHtmlElement(getThirdPartyMapWidget()).amend(
onMountCallback {
thirdPartyLibraryInitMap()
},
onClick --> someObserver,
onResize --> someOtherObserver
)
)
A similar foreignSvgElement
helper is available for SVGs.
I've also added unsafeParseSvgString(dangerousSvgString: String): dom.svg.Element
to help render SVG strings. It requires two steps, but that inconvenience is by design, appropriate for such an unsafe API:
div(
foreignSvgElement(DomApi.unsafeParseSvgString("<svg>....</svg>")),
// And similarly for HTML elements:
foreignHtmlElement(DomApi.unsafeParseHtmlString("<div onclick="alert('pwned')"></div>"))
)
Warning: these unsafeParse*
methods expose you to XSS attacks, so you absolutely must not run them on untrusted strings. Use them for including static SVG icons etc.
All these new methods have a few variations for different use cases, you'll find them when needed.
CSS API Improvements
Unit and function helpers
Previously, if you wanted to set a pixel value to a CSS style prop, you would need to append "px" to your desired number. That's annoying, and with observables it might require the overhead of creating another observable with .map(s"${_}px")
.
You can still do it the old way, but the new API offers several ways to set style values in units like px
, vh
, percent
, ms
, etc.:
div(
margin.px := 12,
marginTop.px := 12,
marginTop.px(12), // remember that all `:=` methods in Laminar are aliased to `apply` for convenience!
marginTop.calc("12px + 50%"),
marginTop.px <-- streamOfInts,
marginTop.px <-- streamOfDoubles
)
The new API is type-safe, so for example backgroundImage.px
does not exist, but .url
does:
div
// Equivalent to CSS: background-image: url("https://example.com/image.png")
backgroundImage.url := "https://example.com/image.png",
backgroundImage.url("https://example.com/image.png"), // same
backgroundImage.url <-- streamOfUrls
)
I haven't decided how to treat some of the more complex composite CSS properties yet, so some of them still only accept strings, which means that you can do borderWidth.px := 12
but can't do border.px := 12
yet. But you can use the new style
string helpers: style.px(12)
returns a plain "12px" string which you can use like border := style.px(12)
.
Get string values from style keyword setters
You could already use keyword shorthands like display.none
– that modifier is equivalent to display := "none"
– and now you can also get string constants from these shorthands with their newly exposed value
property:
div(
// Before
textAlign <-- streamOfBooleans.map(
if (_) "left" else "right"
),
// After – same, but you get to marvel at your IDE knowing these symbols
textAlign <-- streamOfBooleans.map(
if (_) textAlign.left.value else textAlign.right.value
)
)
Want a bit less boilerplate? Define a trivial implicit conversion from StyleSetter[String]
to String
, and you won't need to call .value
manually. This is a bit too magicky to be included in Laminar core though.
Vendor prefixes
Not a super relevant feature these days, but you can now do this to set a style property along with several prefixes:
div(
transition.withPrefixes(_.moz, _.ms, _.webkit) := "all 4s ease"
// and similarly for `<--`.
)
More Options For Window and Document Events
Previously you could access windowEvents
and documentEvents
like this:
windowEvents.onPopState // EventStream[dom.PopStateEvent]
documentEvents.onClick // EventStream[dom.MouseEvent]
Now you need to use a slightly different syntax [Migration]:
windowEvents(_.onPopState) // EventStream[dom.PopStateEvent]
documentEvents(_.onClick) // EventStream[dom.MouseEvent]
You can also specify any event processor now, e.g.:
documentEvents(_.onClick.useCapture.preventDefault)
:=> Unit
Sinks
Currently, you can put any A => ()
or Sink[A]
on the right hand side of -->
methods, including Observer[A]
, EventBus[A]
, and Var[A]
(pro tip – you don't even need to add an explicit .writer
for those, that's oldskool).
We have a lot of helpers and convenience methods optimized to make use of this to reduce boilerplate, for example:
input(
onInput.mapToValue --> myVar.updater(_ :+ _),
onInput.mapToValue.map(_.toInt) --> intObserver,
onInput.mapToValue --> intObserver.contramap[Int](_.toInt),
onClick --> { _ => println("hello") },
onClick.mapTo("hello") --> println
)
Read the Laminar docs – on event handling, vars, observers, etc. – to get a fuller picture.
Unfortunately some of these helpers suffer from being unintuitive, and require a good understanding of Laminar types to use effectively. For example:
myVar.updater
is basically a variation of itsupdate
method that returns an Observer,onClick --> { _ => println("hello") }
is annoyingly verbose, andonClick.mapTo("hello") --> println
splits the responsibility in a rather weird way.
To simplify some of these usage patterns, Laminar is now allowing side effects that are typed simply as :=> Unit
, for example:
import com.raquo.laminar.api.features.unitArrows // enable this feature
input(
onClick --> println("hello"),
onClick --> myVar.update(_.append(""))
)
There is no magic to it, these --> methods
simply accept a Unit
expression, which is then re-evaluated on every event (that's what :=>
does in the function signature). Great suggestion by @Lasering.
Unfortunately, while this kind of API is perfectly safe in Scala 2, that is not the case in Scala 3. Specifically, in Scala 3, the expression if (true) observer
is typed as Unit
and returns ()
, instead of being typed as Any
and returning observer
as in Scala 2.
So, in Scala 3, code like div(onClick --> if (bool) observer)
would compile without warnings, but will not actually call the observer. We can't have that, because beginners might legitimately try to write such code when what they really want is e.g. div(onClick.filter(_ => bool) --> observer)
.
To prevent such accidents, this new unit-based API requires a special import. If you forget the import, the compiler will give you an error pointing to documentation about this caveat. It's up to you to choose between extra brevity and extra safety. With IntelliJ, importing the required implicit is only one command away, so it's not much of an annoyance.
children
Operations
Improvements to After numerous fixes and additional safeguards, it is now safe to do various "weird" things with dynamic child elements:
- You can now safely move an element in between any
children <--
/child <--
inserters (it will be safely removed from its last location, as a DOM element can only exist in one location at a time) - You can now return different types of
children <--
/child <--
inserters fromonMountInsert
on every mount, and have them interoperate safely when re-mounting. - Laminar can now gracefully recover from other code removing an element from inside
children <--
orchild <--
, as long as it uses Laminar APIs to do so. - Migration: Generally all this should work as-is, however under the hood Laminar outputs slightly different DOM elements. Specifically, it may create empty comment nodes (sentinels) before
child <--
elements, andchild.text <--
that are called fromonMountInsert
, to better keep track of the nodes being moved. This does not affect rendering, but your DOM tests might fail if they don't expect those new comment nodes in the DOM.
The list of children is no longer cleared when unmounting a component with children <-- stream
.
- Migration: this change matches the new Airstream semantics. See the general instructions about that in the section below.
Improvements to rendering text nodes:
child.text
now updates the content of text nodes instead of re-creating them on every event.
Rendering Custom Types
Laminar can now render any Component
type for which there is an implicit instance of RenderableNode[Component]
as if it was a regular Laminar element – this includes all methods like child <--
and children <--
. If you are using a DIY component pattern, this should reduce conversion boilerplate and simplify integration.
Similarly, Laminar can now render any A
type for which there is an implicit instance of RenderableText[A]
as if it was a string. This includes usages like child.text <-- observableOfA
.
Also, Laminar now includes built-in instances of RenderableText
for primitive types like Int
, Boolean
, Double
, Long
, so it can render all those types natively now, no need to .toString
everywhere. That said, if you want e.g. custom number formatting, you can provide your own implicit RenderableText[Int]
instance, and the compiler will pick it up instead of the built-in one.
Migration: All of this should be source-compatible with Laminar 0.14.x for most users, however I had to rearrange Laminar's implicits and change <--
method definitions, so some advanced usages might need to be adjusted a bit.
Scala DOM Types Generator
Laminar depends on my Scala DOM Types project to provide listings of all the HTML and SVG tags, attributes, events, and CSS properties. Previously, this information was encoded in Scala DOM Types as Scala traits with a lot of members like lazy val div = htmlElement("div")
. This was workable, but problematic, because these traits were full of complex generic / abstract types, since Scala DOM Types is a general purpose project that does not know anything about Laminar.
For Laminar 15.0.0 I have completely reworked how Scala DOM Types project works. Now, it offers a customizable code generator that a library like Laminar can run at its compile time to generate all the same traits that it used to host, but tailor-made for Laminar, with knowledge of Laminar types, etc.
Long story short, this is a better experience for end users, as you get less abstract methods, using concrete Laminar and scalajs-dom types that are easy to look up. This is also a more flexible solution for UI library authors, as the code generators are generously customizable.
Migration: If you have "com.raquo" %%% "domtypes"
in your build config, remove it, and refer to new Laminar types and traits instead.
New Airstream Semantics
We changed how Airstream observables propagate events to solve long standing ergonomics issues. The migration will require a manual review of some parts of your code – see suggestions below.
==
Checks in Signals
No More Automatic This is perhaps the single most impactful change in this release.
Prior to this release, an Airstream Signal would skip emitting a value that is ==
equal to its current state. For example, if you had val signal = stream.startWith(0)
, and stream
emitted event 1
twice in a row, that signal
would only emit 1
once, it would not emit it the second time, because the value of the state that it represents didn't actually change. This behaviour was supposed to deduplicate / declutter signal updates, and overall better serve the "state" semantic of Signals, but ultimately it caused more issues than it solved.
On a practical level, some ==
checks could be expensive when they evaluate structural equality (e.g. checking the equality of large collections like lists or maps). And this work is performed on every event and at every signal operator, every time you map
/ filter
/ etc. that data. And then when you hand over the result to children <--
, the diffing algorithm essentially did the same job again, resulting in lots of redundant computations.
Also, since event streams don't do ==
checks by default, this behaviour of signals made interop between signals and streams unnecessarily annoying in certain cases.
Worst of all, there wasn't really a good way to disable this behaviour. You would need to either use streams, or wrap your data in useless types like class Ref[A](val v: A)
whose only job is to force comparison by reference equality.
In Airstream 15.0.0 signals no longer perform ==
checks, they emit every event just like streams. This makes all observables' behaviour more uniform, eliminating ==
checks as a decision factor when choosing the data type.
Read on for migration advice.
distinct*
operators
New Both streams and signals now have various distinct*
operators to filter updates using ==
or other comparisons. These can be used to make your signals behave like they did prior to the update, or achieve different, custom logic:
signal.distinct // performs `==` checks, similar to pre-15.0.0 behaviour
signal.distinctBy(_.id) // performs `==` checks on a certain key
signal.distinctByRef // performs reference equality checks
signal.distinctByFn((prevValue, nextValue) => isSame) // custom checks
signal.distinctErrors((prevErr, nextErr) => isSame) // filter errors in the error channel
signal.distinctTry((prevTryValue, nextTryValue) => isSame) // one comparator for both event and error channels
The same operators are available on streams too.
split
Operator Distinction
The split
operator internally uses ==
checks to determine whether each record in the collection has "changed" or not. If not for these ==
checks, split
would trigger a useless update for every record on every incoming event, instead of triggering only on the record that was actually affected by the event.
To maintain this behaviour, the split
operator now has a second parameter called distinctCompose
which indicates how exactly the values are to be distinct-ed, and defaults to _.distinct
, to match the previous behaviour of ==
checks. You can override it to provide a custom distinctor function if desired:
children <-- nodesStream.split(_.id, _.distinctByFn(customComparator))(...)
Migration:
Basically, without automatic deduplication in signals, your code will now start seeing previously suppressed "redundant" events from signals.
The most straightforward solution is to call .distinct
on any Signal that you want to behave like it used to. If you do it on literally every signal in your app, you will effectively get back to the old behaviour, but of course that's gross overkill. The challenge is to find which of your signals need .distinct
– and for that, a manual review is required, there is no way around it. I suggest focusing on the following cases:
- Most important: Signals or Vars that depend on each other in a loop. If you were previously relying on the implicit
==
filter to terminate the loop of two vars updating each other, the lack of such filter might result in an infinite loop now.- If Airstream explodes with a stack overflow error as you're migrating, this is most probably why. Unfortunately making this error more informative / ergonomic is far from trivial, and so far I couldn't afford to spend the time necessary to properly address it.
- Signals that drive side effects, such as network requests or updating
Var
-s in non-idempotent ways. - Signals that emit their updates into EventBus-es or Var-s
- Observables or Vars that accumulate values from some input, e.g.
observable.foldLeft(...)((acc, next) => newAcc)
– ifobservable
is a signal, or a stream that depends on a signal, it might be emitting more events now than before. flatMap
andflatten
calls that might involve per-event side effects.- Any other place where you know that you relied on signals performing
==
checks. - Any state logic that is complex for any reason.
During this migration, adding an extraneous .distinct
is rather unlikely to cause breakage, so if you're not quite sure about some suspicious signal, you can start by .distinct
-ing it. You can try undoing it later as time allows. Remember, you don't need to .distinct
literally every signal to match previous behaviour, it pretty much only matters at the edges, where you trigger non-idempotent side effects.
Correctness aside, this change in Signals also has an impact on performance: Previously, Signal's internal ==
checks used to prevent duplicate values from triggering expensive computations and side effects, such as transforming large lists / maps with signal operators, performing network requests, or updating the DOM.
Now in v15, the performance profile of signals became the same as that of streams, which is to say, it's overall less overhead, but if you're emitting a lot of redundant events, reducing all that chatter might be worth it.
After you have addressed other migration issues, you should consider adding .distinct
to observables (whether streams or signals) that:
- Often consecutively emit several duplicate events, and
- Trigger expensive computations or effects downstream
in order to eliminate redundant computations / effects. However, be careful not to waste too much effort on this, especially don't spend time blindly adding .distinct
to every DOM binding – generally distinct
-ing only makes sense when your observable routinely emits three or more redundant events in a row, and the particular DOM update is happening so often that it's slowing down rendering. For example, if you have a shared observable that updates the DOM in a thousand HTML elements, then by all means, put .distinct
on it if you know that the observable can emit redundant events. Of course, also consider how expensive the given DOM update is. Updating backgroundColor
is several orders of magnitude cheaper than updating a long list of elements provided to children <--
, and the difference is even greater if you are not using Airstream's split
operator.
Signals Now Try to Re-sync After Restarting
This is a solution to #43. Suppose we have:
val parentSignal: Signal[Foo] = ???
val childSignal: Signal[Bar] = parentSignal.map(fooToBar)
span(
backgroundColor <-- childSignal.map(bar => bar.color)
)
Since childSignal
's value is very explicitly derived from parentSignal
's value, you generally expect their values to be in sync at all times. And this is true as long as both signals are started, that is, have observers. However, childSignal
can become stopped if it loses all of its observers, e.g. if this span(...)
was to be unmounted from the DOM.
If this happens, childSignal
will stop listening to parentSignal
(observables are lazy), and if parentSignal
does not get stopped at the same time (it might have other observers), parentSignal
's value might be updated while childSignal
is not listening.
This becomes a problem when childSignal
is restarted again, e.g. because you decided to mount that same exact span(...)
again instead. Prior to Airstream 15.0.0, doing that would have caused childSignal
to be out of sync with parentSignal
because it missed the parent's update while it was stopped – it would only sync up again if / when parentSignal
emitted the next update. In Airstream 15.0.0, childSignal
now "pulls" the parent's latest value when restarting, and updates its own value to match (calling fooToBar
in this case). Note that this "pull" only happens if parentSignal
has actually emitted any value while childSignal
was stopped (otherwise it's just not needed).
This new technique keep signals synced with each other pretty well, but it's not perfect. Some examples:
- The
childSignal
will only get the latest value ofparentSignal
when restarting, even ifparentSignal
emitted several times whilechildSignal
was stopped. This might be important for signals likeparentSignal.scanLeft(...)(...)
. - If you have
signal = parentStream.startWith(1)
, yoursignal
can't "pull" any updates it missed fromparentStream
, because unlike signals, streams don't have a "current value" and don't "remember" their last emitted event – if you miss a stream event, you're not getting it back.
Migration: Review components that reuse elements after they were unmounted. For example, in the snippet below, warningElement
is being reused like this, and in the new Laminar, its child.text
will be updated to sync up with parentSignal
whenever boolSignal
emits true
, even if parentSignal
never emits while warningElement
is actually mounted:
val boolSignal: Signal[Boolean] = ???
val parentSignal: Signal[String] = ???
val warningElement = div(
h1("The yeti is onto us!"),
child.text <-- parentSignal.map(_.toUpperCase)
)
div(
child <-- boolSignal.map(if (_) warningElement else emptyNode)
)
Remember that this change does not affect elements that are not reused after being unmounted. Note that child
/ children
fed by split
/ splitOne
do not reuse elements in this manner by themselves when switching between children, this changes affects the specific pattern where you save a Laminar element in a val
and then use the same val
repeatedly in child <-- ...
or children <-- ...
.
signal.changes re-sync after restarting
Unlike other streams, the signal.changes
stream does re-sync when restarting – and just like other signals, it does it only if signal
has emitted a value while signal.changes
was stopped.
However, streams are unable to propagate updates without emitting an event to all of their children. So, when re-syncing upon restarting, signal.changes
emits the signal's latest value in a new transaction, and more importantly, this transaction is shared between all the .changes
streams of your various signals that are being restarted at the same time. For example, if you re-mount a previously unmounted element which uses a bunch of .changes
streams on unrelated signals provided by the parent component, all of those .changes streams will emit in the same transaction, even if it is normally impossible for them to emit in the same transaction. For example:
div(
parentSignal.changes.map(foo) --> fooObserver,
parentSignal.changes.flatMap(_ => EventStream.fromValue(bar)) --> barObserver
)
changes.map(...)
and changes.flatMap(...)
normally can never emit in the same transaction, but in this case, when re-syncing, they will. This is undesirable, but for the re-syncing use case, that is the cost of re-syncing the .changes
stream. I think overall this strategy provides the best ergonomics, as users are much more likely to be annoyed at signal.changes
not re-syncing at all, than they are to run into a situation where this imperfection is causing a problem due to using a shared transaction.
Migration: Review components that reuse elements after they were unmounted – see the warningElement
example above. In the snippet above, you can avoid the new re-syncing logic by replacing val warningElement
with def warningElement
– this will cause Laminar to re-create the element instead of re-mounting an existing one – this will not match previous behaviour, but will solve any issues you might be having due to the shared transaction mechanism in the .changes
re-sync.
Observables No Longer Reset Their Internal State When Stopped
Prior to Airstream 15.0.0, our design generally assumed that when an observable is stopped, the user would want to clear / reset its state. Combined with source observables like EventStream.fromValue(v)
and AjaxEventStream
defaulting to emitOnce = false
, that is, re-emitting their events on every start, this was a reasonable way to approach the problem of properly reviving Laminar components after they have been unmounted and mounted again.
As a concrete example, in past Airstream versions, after stream1.combineWith(stream2)
was restarted, it would not start emitting events again until both stream1 and stream2 emitted a new event, because when the combined stream was stopped, it "forgot" the previous events that its parent streams emitted.
So in that example, if your stream1
and stream2
streams also re-emitted during this restart, everything would be fine, the combined stream would emit the new combined event, and you would get the expected result. However, not all streams behaved that way, and so this restarting paradigm was not always helpful.
The new restarting paradigm is pretty much the opposite – if stopped and restarted, the observables generally remember their last known state, and the signals even try to re-sync their state when they're started again (see the section above).
Airstream's general paradigm now is to "pause" the observables when they are stopped, and seamlessly "resume" them when they are restarted, instead of tearing down on stop, and restarting them from scratch on restart.
Migration: Review components that reuse elements after they were unmounted – see the warningElement
example above. Remember that this change does not affect elements that are not reused after being unmounted.
New Airstream Features
splitByIndex
operator
New Sometimes you want to use the split
operator to efficiently render a dynamic collection of items, but these items don't have a suitable id
-like key required by split
. There are a couple ways to work around this, but the easiest is using the index of an item in the collection as its unique key. Now there is a special operator that does it for you:
val modelsStream: EventStream[List[Model]] = ???
children <-- modelsStream.splitByIndex((ix, initialModel, modelSignal) => div(...))
splitOption
operator
New Another convenience method lets you split an observable of Option-s using .isDefined as key:
val modelOptionStream: EventStream[Option[Model]] = ???
child <-- modelOptionStream.splitOption(
(initialModel, modelSignal) => div(...),
ifEmpty = div("No model currently")
)
You can provide ifEmpty = emptyNode
if you don't need it. That said, the regular split
works with options too, in fact that's how splitOption
is implemented.
split
operator
Duplicate key warnings for the Airstream's split
operator does not tolerate items with non-unique keys – this is simply invalid input for it, and it will crash and burn if provided such bad data.
The new Airstream version enables duplicate key warnings by default. Your code will still break if the split
operator encounters duplicate keys, but it will first print a warning in the browser console listing the duplicate keys at fault.
Thus, these new warnings do not affect the execution of your code, and can be safely turned on for debugging or turned off for performance. You can adjust this setting both for your entire application, and for individual usages of split
:
// Disable duplicate key warnings by default
DuplicateKeysConfig.setDefault(DuplicateKeysConfig.noWarnings)
// Disable warnings for just one split observable
stream.split(_.id, duplicateKeys = DuplicateKeysConfig.noWarnings)(...)
take
and drop
operators
New The new stream.take(numEvents)
operator returns a stream that re-emits the first numEvents
events emitted by the parent stream
, and then stops emitting. stream.drop(numEvents)
does the opposite, skipping the first numEvents
events and then starting to re-emit everything that the parent stream
emits.
These operators are available with several signatures:
stream.take(numEvents = 5)
stream.takeWhile(ev => passes(ev)) // stop taking when `passes(ev)` returns `false`
stream.takeUntil(ev => passes(ev)) // stop taking when `passes(ev)` returns `true`
stream.drop(numEvents = 5)
stream.dropWhile(ev => passes(ev)) // stop skipping when `passes(ev)` returns `false`
stream.dropUntil(ev => passes(ev)) // stop skipping when `passes(ev)` returns `true`
Like some other operators, these have an optional resetOnStop
argument. Defaults to false
, but if set to true
, they "forget" all past events, and are reset to their original state if the parent stream is stopped and then started again.
filterWith
operator
New stream.filterWith(signalOfBooleans)
emits events from stream
, but only when the given signal's (or Var's) current value is true
.
Can also be used with Laminar's new compose
method to filter DOM events:
div(onClick.compose(_.filterWith(clickEnabledVar)) --> observer)
FetchStream
Airstream core now has a convenient interface to make network requests using the modern Fetch browser API:
FetchStream.get(
url,
_.redirect(_.follow),
_.referrerPolicy(_.`no-referrer`),
_.abortStream(...)
) // EventStream[String] of response body
You can also get a stream of raw dom.Response
-s, or use a custom codec for requests and responses, all with the same API:
FetchStream.raw.get(url) // EventStream[dom.Response]
val Fetch = FetchStream.withCodec(encodeRequest, decodeResponse)
Fetch.post(url, _.body(myRequest)) // EventStream[MyResponse]
collectSome
, collectOpt
operators
New val stream: EventStream[Option[A]] = ???
stream.collectSome // EventStream[A]
val stream: EventStream[List[A]]
streamOfList.collectOpt(NonEmptyList.from) // EventStream[NonEmptyList[A]]
// Note: NonEmptyList.from(list) returns Option[NonEmptyList[A]]
EventStream.delay(ms)
shorthand
New EventStream.delay(ms, optionalValue)
emits optionalValue
(or ()
if omitted) ms
milliseconds after the stream is started. Useful to delay some action after the component is mounted, e.g.:
div(
EventStream.delay(5000) --> showBelovedMarketingPopup
)
New: Signal.fromFuture with initial value
Signal.fromFuture(future)
produces a Signal[Option[A]]
which you can work around, but is annoying. Now you can specify initialValue: A
as the second argument, and get a Signal[A]
that will start with that value if the future
is not yet resolved.
New: Flatten streams of signals
We now have a FlattenStrategy that supports this particular combination of observables before. This: stream.flatMap(v => makeFooSignal(v))
now returns EventStream[Foo]
, and works similar to switching streams, with the signal's current value considered an "event" when switching to a new signal.
As a result, you can now also flatten Observable[Signal[A]]
into Observable[A]
.
throwFailure
operator
New Turns Observable[Try[A]]
into Observable[A]
, moving the failure into the error channel. For when you want to un-recover from recoverToTry
.
scala.Future
integration
Changes to Airstream Airstream lets you create streams and signals from scala Futures and JS promises. Future-based functionality is now implemented using js.Promise
, instead of the opposite, to avoid surprising behaviour in some edge cases.
This means that if you don't explicitly use Futures, your code is now scala.Future-free, and your JS bundle should get slimmer as a result (unless your other dependencies still use Futures).
Migration: This results in the following breaking changes:
API:
Signal.fromFuture
always emits asynchronously now, that is, it always starts with aNone
value (or the provided initial value), even if the future/promise has already resolved when it's observed (because there's absolutely no way to synchronously observe the content of ajs.Promise
).EventStream.fromFuture
does not offer theemitIfFutureCompleted
option anymore, it is now always on. It also has a new option:emitOnce
.API: Internet Explorer 11 support now requires a
js.Promise
polyfill to usefromFuture
methods, because Internet Explorer does not natively support JS Promises. See stackoverflow.API: Removed
SwitchFutureStrategy
, you can't directly flatten observables of futures anymore, because that behaviour isn't defined well enough.- Migration: When flattening observables of futures, wrap them in
EventStream.fromFuture
orSignal.fromFuture
to make sure that you're getting what you expect. Then SwitchStreamStrategy or SwitchSignalStrategy will apply.
- Migration: When flattening observables of futures, wrap them in
API: Disabled implicit conversions from Future and js.Promise to
Source
. They're not smooth / obvious enough to be implicit.- Migration: same, doing it explicitly.
API:
fromFuture
methods require an implicitExecutionContext
now.- Migration: Read this explanation by the Scala.js team, and choose which execution context you want to use.
Timing: Future-based streams have a few milliseconds of extra latency now as the futures need to be translated to
js.Promise
. Since they're asynchronous by nature, this shouldn't be a problem, but if you're very unlucky, this might expose previously unknown race conditions in your code.
Minor Breaking Changes
Migration should be obvious for these. Most of these likely won't even affect you.
Laminar
We no longer use names that start with or contain the
$
symbol, because even though such code compiles, names containing$
are reserved for the Scala compiler use. Laminar itself used such names very sparingly, but if you did reference those names, replacements should be obvious as they are mostly method param names. See #127. Thanks to @TheElectronWill for noticing!The concept of
TypedTargetEvent
is eliminated from Laminar – those complex non-native types were confusing and not very effective. I recommended using the newmapToValue
/mapToChecked
helpers as they cover the most popular reason to accessevent.target
, but you can also useinContext { thisNode => ... }
. If you really truly need access toevent.target
and notthisNode
, you can use the newmapToTargetAs[dom.html.Input]
event processor, but it's essentiallyasInstanceOf
, so be careful.Laminar node types like
ChildNode
don’t extend the correspondingcom.raquo.domtypes
types anymore (they were removed from domtypes)Some rarely used CSS style shorthands like
unicodeBidi.embed
were removed. Use regular strings to set the desired values, e.g.unicodeBidi := "embed"
CompositeKey
now extendsKey
, and the various:=
methods now returnCompositeKeySetter
, a more specific subtype of Setter. Migration: watch out if you use pattern matching onKey
subtypes.Fix:
DomApi.createHtmlElement
acceptsHtmlTag
now instead ofHtmlElement
. Similarly forcreateSvgElement
.Removed deprecated methods
Airstream
Var.update
no longer throws exceptions on invalid input, all errors are now reported via Airstream's unhandled-errors mechanism for consistency (previously the behaviour depended on the type of error).split
operator now provides signals only, no streams. This goes both for the return value of the operator and the argument type of the callback that it accepts.Remove
splitIntoSignals
method – usesplit
(see above)Debugger
doesn't havetopoRank
field anymore (it was useless)Remove
Id[A] = A
type fromutil
– define your own if you need itRemove hidden
Ref
util class – use the newdistinct*
methodsEventStream.periodic
resetOnStop
default changed fromtrue
tofalse
in accordance with new semanticsRemoved
emitInitial
option. It's alwaystrue
now. Use the newdrop(1, resetOnStop = true)
operator to approximate previousemitInitial = false
behaviour.
Signal.fromCustomSource
now requiresTry[A]
instead ofA
for initial value.Removed deprecated methods
Changes Only Relevant To Laminar & Airstream Extensions
Migration: These changes are only relevant to library authors and advanced users who extend Airstream and Laminar classes – the vast majority of regular end users are not affected by these changes. This is not a 100% exhaustive list of such internal changes, but it should cover all significant changes, as well as changes that are hard to grasp from just the compiler errors.
All
<X>EventStream
types were renamed to<X>Stream
, except forEventStream
itself andDomEventStream
. The renamed types are not really user-facing except forAjaxEventStream
, for which a deprecated alias is provided for now.- You know what it feels like to have
<X>Stream.scala
and<X>Signal.scala
files always next to each other when sorted alphabetically? Bliss.
- You know what it feels like to have
Modifier
type moved from Scala DOM Types to LaminarModifier
's generic type param is constrained now, so you can’t useModifier[_]
won't compile anymore, useModifier[_ <: ReactiveElement.Base]
instead.No more
SingleParentObservable
. Replace withSingleParentSignal
andSingleParentStream
Splittable
now requiresempty
.IdSplittable
is removed.Rename
Protected.maxParentTopoRank
toProtected.maxTopoRank
Laminar
Key
types likeReactiveProp
,ReactiveHtmlAttr
, etc. don’t extend the correspondingcom.raquo.domtypes
types anymore (those types were removed from Scala DOM Types), and also they were renamed (see "User-facing renamings" section below)Laminar
Node
types likeChildNode
don’t extend the correspondingcom.raquo.domtypes
types anymore (those types were removed from Scala DOM Types)ReactiveHtmlElement
andReactiveSvgElement
now acceptref
as a parameter. Use this wisely. Note that new helper methods are now available to inject foreign elements into Laminar tree (see above), so you shouldn’t need to use these constructors directly.BaseObservable#equals
method now always compares observables by reference, and that's madefinal
. In practice this means that creating case class subtypes of observables won’t accidentally break Airstream.Subscription.isKilled
method is now public, you can use it to check if it's safe to kill a subscription.Some Laminar implicit conversions like
nodesArrayToInserter
andnodesArrayToModifier
were renamed, and new ones were added. Overall they were reworked to useRenderableNode
andRenderableText
typeclasses.AsIsCodec[A]
trait replaced with a type alias, which is immediately deprecated. UseCodec[A, A]
type if needed.Individual N-arity classes like
CombineSignal2
were replaced withCombineSignalN
factory functions to reduce bundle size.Semi-internal class renamings:
MaybeChildReceiver
->ChildOptionReceiver
,TextChildReceiver
->ChildTextReceiver
,Assorted small changes to internal types and methods to account for new features such as
RenderableNode
andRenderableText
, as well as simplifications like removing unneeded type params.
New Minor Conveniences
Laminar
mapToFiles
to get list of files for upload:input(typ("file"), onChange.mapToFiles --> filesObserver))
setValue
andsetChecked
event processors (similar tosetAsValue
/setAsChecked
, but let you use an out-of-band value)HtmlMod
,SvgMod
type aliasesModifier.apply
Setter.empty
Modifier.Base
,Setter.Base
,Binder.Base
type aliasesDomApi.debugNodeOuterHtml
,DomApi.debugNodeInnerHtml
(null-safe)
Airstream
EventBus.apply
EventStream.withUnitCallback
to get a stream that's updatable via a parameter-less callbackmapToUnit
operator, justmapTo(())
stream1.mergeWith(stream2, stream3)
– alias forEventStream.merge(stream1, stream2, stream3)
Other Minor Changes
Laminar
Fix: Prevent text cursor from jumping to the end of the input in Safari when using the controlled-input pattern without using Laminar’s
controlled
method. (#110)API:
StyleSetter
no longer has a type param.API:
cls
is now a composite attribute, not a composite property. There should be no observable difference other than change in type.API: You can now pass the two arguments to
controlled()
in reverse order tooBuild: You need a more modern version of node.js to run Laminar tests now. I forget the exact version, but I think v14 is the minimum now. I recommend installing a much newer one, perhaps v18 LTS.
Airstream
API: DerivedVar zoomOut parameter now provides the parent Var's current value as the first param. Migration: update existing usages of
parentVar.zoom(in)(out)
method toparentVar.zoom(in)((parentValue, derivedValue) => out(derivedValue))
, or better yet, make use ofparentValue
instead of callingparentVar.now()
in yourout
function.API:
matchStreamOrSignal
is now available onObservable
Fix: Debug logging does not wrap text in extraneous "Some()" and "None" anymore
API: Removed
cacheInitialValue
option fromCustomSignalSource
– it did not work, so this should not change any behaviour
User-facing Renamings
Migration: find and rename these as they fail to compile. Other renamings will show deprecation warnings.
Laminar
Many Laminar DOM tag names now have a
Tag
suffix, e.g.time
->timeTag
,main
->mainTag
,header
->headerTag
,section
->sectionTag
.Laminar
Key
types were renamed: likeReactiveProp
->HtmlProp
,ReactiveStyle
->StyleProp
,ReactiveEventProp
->EventProp
,ReactiveHtmlAttr
->HtmlAttr
,ReactiveSvgAttr
->SvgAttr
,ReactiveComplexHtmlKeys
->ComplexHtmlKeys
,ReactiveComplexSvgKeys
->ComplexSvgKeys
,CompositeProp[_]
->CompositeHtmlProp
.- Note that
ReactiveHtmlElement
andReactiveSvgElement
types keep their current names!
- Note that
EventListener.Any
->EventListener.Base
customHtmlAttr
,customSvgAttr
,customProp
,customStyle
renamed – see deprecation noticescontent
style prop renamed tocontentCss
to avoid being shadowed by a common variable name.name
attribute renamed tonameAttr
KeyUpdater.$value
->KeyUpdater.values
Airstream
Observable operators:
contramapOpt
->contracollectOpt
foldLeft
andfoldLeftRecover
->scanLeft
andscanLeftRecover
Manual dynamic subscription factories require that you don't manually kill the
Subscription
that you create, that you let the resulting DynamicSubscription manage it. They were renamed and commented to reflect that:DynamicSubscription.apply
->DynamicSubscription.unsafe
ReactiveElement.bindSubscription
->ReactiveElement.bindSubscriptionUnsafe
Debug API
debugSpyInitialEval
->debugSpyEvalFromParent
debugBreakInitialEval
->debugBreakEvalFromParent
debugLogInitialEval
->debugLogEvalFromParent
Debugger.onInitialEval
->Debugger.onCurrentValueFromParent
Thank You
The development of Laminar itself is kindly sponsored by people who use it in their businesses and personal projects.
GOLD sponsors supporting Laminar:
Thank you for supporting me! ❤️