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.Futureintegration - 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.updateris basically a variation of itsupdatemethod that returns an Observer,onClick --> { _ => println("hello") }is annoyingly verbose, andonClick.mapTo("hello") --> printlnsplits 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.
Improvements to children Operations
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 fromonMountInserton 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.textnow 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.
No More Automatic == Checks in Signals
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.
New distinct* operators
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)– ifobservableis a signal, or a stream that depends on a signal, it might be emitting more events now than before. flatMapandflattencalls 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
childSignalwill only get the latest value ofparentSignalwhen restarting, even ifparentSignalemitted several times whilechildSignalwas stopped. This might be important for signals likeparentSignal.scanLeft(...)(...). - If you have
signal = parentStream.startWith(1), yoursignalcan'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
New splitByIndex operator
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(...))
New splitOption operator
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.
Duplicate key warnings for the split operator
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)(...)
New take and drop operators
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.
New filterWith operator
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]
New collectSome, collectOpt operators
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]]
New EventStream.delay(ms) shorthand
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].
New throwFailure operator
Turns Observable[Try[A]] into Observable[A], moving the failure into the error channel. For when you want to un-recover from recoverToTry.
Changes to Airstream scala.Future integration
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.fromFuturealways emits asynchronously now, that is, it always starts with aNonevalue (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.fromFuturedoes not offer theemitIfFutureCompletedoption anymore, it is now always on. It also has a new option:emitOnce.API: Internet Explorer 11 support now requires a
js.Promisepolyfill to usefromFuturemethods, 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.fromFutureorSignal.fromFutureto 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:
fromFuturemethods require an implicitExecutionContextnow.- 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
TypedTargetEventis eliminated from Laminar – those complex non-native types were confusing and not very effective. I recommended using the newmapToValue/mapToCheckedhelpers as they cover the most popular reason to accessevent.target, but you can also useinContext { thisNode => ... }. If you really truly need access toevent.targetand notthisNode, you can use the newmapToTargetAs[dom.html.Input]event processor, but it's essentiallyasInstanceOf, so be careful.Laminar node types like
ChildNodedon’t extend the correspondingcom.raquo.domtypestypes anymore (they were removed from domtypes)Some rarely used CSS style shorthands like
unicodeBidi.embedwere removed. Use regular strings to set the desired values, e.g.unicodeBidi := "embed"CompositeKeynow extendsKey, and the various:=methods now returnCompositeKeySetter, a more specific subtype of Setter. Migration: watch out if you use pattern matching onKeysubtypes.Fix:
DomApi.createHtmlElementacceptsHtmlTagnow instead ofHtmlElement. Similarly forcreateSvgElement.Removed deprecated methods
Airstream
Var.updateno 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).splitoperator 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
splitIntoSignalsmethod – usesplit(see above)Debuggerdoesn't havetopoRankfield anymore (it was useless)Remove
Id[A] = Atype fromutil– define your own if you need itRemove hidden
Refutil class – use the newdistinct*methodsEventStream.periodic
resetOnStopdefault changed fromtruetofalsein accordance with new semanticsRemoved
emitInitialoption. It's alwaystruenow. Use the newdrop(1, resetOnStop = true)operator to approximate previousemitInitial = falsebehaviour.
Signal.fromCustomSourcenow requiresTry[A]instead ofAfor 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>EventStreamtypes were renamed to<X>Stream, except forEventStreamitself 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.scalaand<X>Signal.scalafiles always next to each other when sorted alphabetically? Bliss.
- You know what it feels like to have
Modifiertype 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 withSingleParentSignalandSingleParentStreamSplittablenow requiresempty.IdSplittableis removed.Rename
Protected.maxParentTopoRanktoProtected.maxTopoRankLaminar
Keytypes likeReactiveProp,ReactiveHtmlAttr, etc. don’t extend the correspondingcom.raquo.domtypestypes anymore (those types were removed from Scala DOM Types), and also they were renamed (see "User-facing renamings" section below)Laminar
Nodetypes likeChildNodedon’t extend the correspondingcom.raquo.domtypestypes anymore (those types were removed from Scala DOM Types)ReactiveHtmlElementandReactiveSvgElementnow acceptrefas 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#equalsmethod 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.isKilledmethod is now public, you can use it to check if it's safe to kill a subscription.Some Laminar implicit conversions like
nodesArrayToInserterandnodesArrayToModifierwere renamed, and new ones were added. Overall they were reworked to useRenderableNodeandRenderableTexttypeclasses.AsIsCodec[A]trait replaced with a type alias, which is immediately deprecated. UseCodec[A, A]type if needed.Individual N-arity classes like
CombineSignal2were replaced withCombineSignalNfactory 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
RenderableNodeandRenderableText, as well as simplifications like removing unneeded type params.
New Minor Conveniences
Laminar
mapToFilesto get list of files for upload:input(typ("file"), onChange.mapToFiles --> filesObserver))setValueandsetCheckedevent processors (similar tosetAsValue/setAsChecked, but let you use an out-of-band value)HtmlMod,SvgModtype aliasesModifier.applySetter.emptyModifier.Base,Setter.Base,Binder.Basetype aliasesDomApi.debugNodeOuterHtml,DomApi.debugNodeInnerHtml(null-safe)
Airstream
EventBus.applyEventStream.withUnitCallbackto get a stream that's updatable via a parameter-less callbackmapToUnitoperator, 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
controlledmethod. (#110)API:
StyleSetterno longer has a type param.API:
clsis 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 ofparentValueinstead of callingparentVar.now()in youroutfunction.API:
matchStreamOrSignalis now available onObservableFix: Debug logging does not wrap text in extraneous "Some()" and "None" anymore
API: Removed
cacheInitialValueoption 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
Tagsuffix, e.g.time->timeTag,main->mainTag,header->headerTag,section->sectionTag.Laminar
Keytypes were renamed: likeReactiveProp->HtmlProp,ReactiveStyle->StyleProp,ReactiveEventProp->EventProp,ReactiveHtmlAttr->HtmlAttr,ReactiveSvgAttr->SvgAttr,ReactiveComplexHtmlKeys->ComplexHtmlKeys,ReactiveComplexSvgKeys->ComplexSvgKeys,CompositeProp[_]->CompositeHtmlProp.- Note that
ReactiveHtmlElementandReactiveSvgElementtypes keep their current names!
- Note that
EventListener.Any->EventListener.BasecustomHtmlAttr,customSvgAttr,customProp,customStylerenamed – see deprecation noticescontentstyle prop renamed tocontentCssto avoid being shadowed by a common variable name.nameattribute renamed tonameAttrKeyUpdater.$value->KeyUpdater.values
Airstream
Observable operators:
contramapOpt->contracollectOptfoldLeftandfoldLeftRecover->scanLeftandscanLeftRecover
Manual dynamic subscription factories require that you don't manually kill the
Subscriptionthat you create, that you let the resulting DynamicSubscription manage it. They were renamed and commented to reflect that:DynamicSubscription.apply->DynamicSubscription.unsafeReactiveElement.bindSubscription->ReactiveElement.bindSubscriptionUnsafe
Debug API
debugSpyInitialEval->debugSpyEvalFromParentdebugBreakInitialEval->debugBreakEvalFromParentdebugLogInitialEval->debugLogEvalFromParentDebugger.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! ❤️