March 22, 2023
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 ina few short examples orone long video.
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 version15.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.
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 theSA 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 thedemo application and further information in theapplication architecture documentation.
Laminar development (and documentation and testing and community support) isa lot of work, and sponsorship revenue makes a huge difference in my ability to do all this. A sincere thank you toall 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:
scala.Future
integrationIn addition to the very detailed release notes below, existing users of Laminar should read these new documentation sections:
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 onenotable 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 libraryew to get consistently fast implementations of methods likeindexOf
andforEach
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
andcompose
for DOM eventsTypically, 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)
ThiscomposeEvents
method always rubbed me the wrong way because it's not discoverable – you can't find it via autocomplete after typingonClick.
. Something prevented me from doing this earlier, but now I've realized that I can offer equivalent functionality as anonClick.compose
method which works just like the observables' nativecompose
method:
div( onClick.compose(_.delay(1)) --> observer, onClick .preventDefault .map(getFoo) .compose(_.filter(isGoodFoo).startWith(initialFoo)) --> observer,)
I've also added a newflatMap
method which is useful when you want to create a new observable on every DOM event. For example:
defmakeAjaxRequest():EventStream[Response] = ???input( onInput .mapToValue .flatMap(txt => makeAjaxRequest(txt)) --> observer)
If you use this newflatMap
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 specializedflatMapStream
andflatMapSignal
methods which use simpler types, and don't trigger the false error in the IDE.
BetweenflatMap
andcompose
, obtaining observables' functionality from DOM events is much more natural now, socomposeEvents
is now deprecated.Migration is trivial: rewritecomposeEvents(a)(b)
toa.compose(b)
.
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:
defgetThirdPartyMapWidget(): 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 similarforeignSvgElement
helper is available for SVGs.
I've also addedunsafeParseSvgString(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></div>")))
Warning: theseunsafeParse*
methods expose you toXSS 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.
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 likepx
,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 examplebackgroundImage.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 doborderWidth.px := 12
but can't doborder.px := 12
yet. But you can use the newstyle
string helpers:style.px(12)
returns a plain "12px" string which you can use likeborder := style.px(12)
.
You could already use keyword shorthands likedisplay.none
– that modifier is equivalent todisplay := "none"
– and now you can also get string constants from these shorthands with their newly exposedvalue
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.valueelse textAlign.right.value ))
Want a bit less boilerplate? Define a trivial implicit conversion fromStyleSetter[String]
toString
, and you won't need to call.value
manually. This is a bit too magicky to be included in Laminar core though.
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 `<--`.)
Previously you could accesswindowEvents
anddocumentEvents
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
SinksCurrently, you can put anyA => ()
orSink[A]
on the right hand side of-->
methods, includingObserver[A]
,EventBus[A]
, andVar[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 featureinput( onClick --> println("hello"), onClick --> myVar.update(_.append("")))
There is no magic to it, these--> methods
simply accept aUnit
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 expressionif (true) observer
is typed asUnit
and returns()
, instead of being typed asAny
and returningobserver
as in Scala 2.
So, in Scala 3, code likediv(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
OperationsAfter numerous fixes and additional safeguards, it is now safe to do various "weird" things with dynamic child elements:
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)children <--
/child <--
inserters fromonMountInsert
on every mount, and have them interoperate safely when re-mounting.children <--
orchild <--
, as long as it uses Laminar APIs to do so.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 withchildren <-- stream
.
Improvements to rendering text nodes:
child.text
now updates the content of text nodes instead of re-creating them on every event.Laminar can now render anyComponent
type for which there is an implicit instance ofRenderableNode[Component]
as if it was a regular Laminar element – this includes all methods likechild <--
andchildren <--
. If you are using a DIY component pattern, this should reduce conversion boilerplate and simplify integration.
Similarly, Laminar can now render anyA
type for which there is an implicit instance ofRenderableText[A]
as if it was a string. This includes usages likechild.text <-- observableOfA
.
Also, Laminar now includes built-in instances ofRenderableText
for primitive types likeInt
,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 implicitRenderableText[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.
Laminar depends on myScala 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 likelazy 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.
We changed how Airstream observables propagate events to solve long standing ergonomics issues. Themigration will require a manual review of some parts of your code – see suggestions below.
==
Checks in SignalsThis 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 hadval signal = stream.startWith(0)
, andstream
emitted event1
twice in a row, thatsignal
would only emit1
once, it wouldnot 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 youmap
/filter
/ etc. that data. And then when you hand over the result tochildren <--
, 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 likeclass 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 formigration advice.
distinct*
operatorsBoth streams and signals now have variousdistinct*
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 behavioursignal.distinctBy(_.id)// performs `==` checks on a certain keysignal.distinctByRef// performs reference equality checkssignal.distinctByFn((prevValue, nextValue) => isSame)// custom checkssignal.distinctErrors((prevErr, nextErr) => isSame)// filter errors in the error channelsignal.distinctTry((prevTryValue, nextTryValue) => isSame)// one comparator for both event and error channels
The same operators are available on streams too.
split
Operator DistinctionThesplit
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, thesplit
operator now has a second parameter calleddistinctCompose
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))(...)
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:
==
filter to terminate the loop of two vars updating each other, the lack of such filter might result in an infinite loop now.Var
-s in non-idempotent ways.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.==
checks.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:
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 – generallydistinct
-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. UpdatingbackgroundColor
is several orders of magnitude cheaper than updating a long list of elements provided tochildren <--
, and the difference is even greater if you are not using Airstream'ssplit
operator.
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))
SincechildSignal
's value is very explicitly derived fromparentSignal
's value, you generally expect their values to be in sync at all times. And this is true as long as both signals arestarted, that is, have observers. However,childSignal
can becomestopped if it loses all of its observers, e.g. if thisspan(...)
was to beunmounted from the DOM.
If this happens,childSignal
will stop listening toparentSignal
(observables are lazy), and ifparentSignal
does not get stopped at the same time (it might have other observers),parentSignal
's value might be updated whilechildSignal
is not listening.
This becomes a problem whenchildSignal
is restarted again, e.g. because you decided to mount that same exactspan(...)
again instead. Prior to Airstream 15.0.0, doing that would have causedchildSignal
to be out of sync withparentSignal
because it missed the parent's update while it was stopped – it would only sync up again if / whenparentSignal
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 (callingfooToBar
in this case). Note that this "pull" only happens ifparentSignal
has actually emitted any value whilechildSignal
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:
childSignal
will only get thelatest value ofparentSignal
when restarting, even ifparentSignal
emitted several times whilechildSignal
was stopped. This might be important for signals likeparentSignal.scanLeft(...)(...)
.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, itschild.text
will be updated to sync up withparentSignal
wheneverboolSignal
emitstrue
, even ifparentSignal
never emits whilewarningElement
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 (_) warningElementelse emptyNode))
Remember that this change does not affect elements that are not reused after being unmounted. Note thatchild
/children
fed bysplit
/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 aval
and then use the sameval
repeatedly inchild <-- ...
orchildren <-- ...
.
Unlike other streams, thesignal.changes
stream does re-sync when restarting – and just like other signals, it does it only ifsignal
has emitted a value whilesignal.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(...)
andchanges.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 atsignal.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 thewarningElement
example above. In the snippet above, you can avoid the new re-syncing logic by replacingval warningElement
withdef 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.
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 likeEventStream.fromValue(v)
andAjaxEventStream
defaulting toemitOnce = false
, that is, re-emitting their events onevery 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, afterstream1.combineWith(stream2)
was restarted, it would not start emitting events again untilboth 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 yourstream1
andstream2
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 thewarningElement
example above. Remember that this change does not affect elements that are not reused after being unmounted.
splitByIndex
operatorSometimes you want to use thesplit
operator to efficiently render a dynamic collection of items, but these items don't have a suitableid
-like key required bysplit
. 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
operatorAnother 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 provideifEmpty = emptyNode
if you don't need it. That said, the regularsplit
works with options too, in fact that's howsplitOption
is implemented.
split
operatorAirstream'ssplit
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 thesplit
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 ofsplit
:
// Disable duplicate key warnings by defaultDuplicateKeysConfig.setDefault(DuplicateKeysConfig.noWarnings)// Disable warnings for just one split observablestream.split(_.id, duplicateKeys =DuplicateKeysConfig.noWarnings)(...)
take
anddrop
operatorsThe newstream.take(numEvents)
operator returns a stream that re-emits the firstnumEvents
events emitted by the parentstream
, and then stops emitting.stream.drop(numEvents)
does the opposite, skipping the firstnumEvents
events and then starting to re-emit everything that the parentstream
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 optionalresetOnStop
argument. Defaults tofalse
, but if set totrue
, they "forget" all past events, and are reset to their original state if the parent stream is stopped and then started again.
filterWith
operatorstream.filterWith(signalOfBooleans)
emits events fromstream
, but only when the given signal's (or Var's) current value istrue
.
Can also be used with Laminar's newcompose
method to filter DOM events:
div(onClick.compose(_.filterWith(clickEnabledVar)) --> observer)
Airstream core now has a convenient interface to make network requests using the modernFetch browser API:
FetchStream.get( url, _.redirect(_.follow), _.referrerPolicy(_.`no-referrer`), _.abortStream(...))// EventStream[String] of response body
You can also get a stream of rawdom.Response
-s, or use a custom codec for requests and responses, all with the same API:
FetchStream.raw.get(url)// EventStream[dom.Response]
valFetch =FetchStream.withCodec(encodeRequest, decodeResponse)Fetch.post(url, _.body(myRequest))// EventStream[MyResponse]
collectSome
,collectOpt
operatorsval 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)
shorthandEventStream.delay(ms, optionalValue)
emitsoptionalValue
(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)
Signal.fromFuture(future)
produces aSignal[Option[A]]
which you can work around, but is annoying. Now you can specifyinitialValue: A
as the second argument, and get aSignal[A]
that will start with that value if thefuture
is not yet resolved.
We now have a FlattenStrategy that supports this particular combination of observables before. This:stream.flatMap(v => makeFooSignal(v))
now returnsEventStream[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 flattenObservable[Signal[A]]
intoObservable[A]
.
throwFailure
operatorTurnsObservable[Try[A]]
intoObservable[A]
, moving the failure into the error channel. For when you want to un-recover fromrecoverToTry
.
scala.Future
integrationAirstream lets you create streams and signals from scala Futures and JS promises. Future-based functionality is now implemented usingjs.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 ajs.Promise
polyfill to usefromFuture
methods, because Internet Explorer does not natively support JS Promises. Seestackoverflow.
API: RemovedSwitchFutureStrategy
, you can't directly flatten observables of futures anymore, because that behaviour isn't defined well enough.
EventStream.fromFuture
orSignal.fromFuture
to make sure that you're getting what you expect. Then SwitchStreamStrategy or SwitchSignalStrategy will apply.API: Disabled implicit conversions from Future and js.Promise toSource
. They're not smooth / obvious enough to be implicit.
API:fromFuture
methods require an implicitExecutionContext
now.
Timing: Future-based streams have a few milliseconds of extra latency now as the futures need to be translated tojs.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.
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 ofTypedTargetEvent
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 likeChildNode
don’t extend the correspondingcom.raquo.domtypes
types anymore (they were removed from domtypes)
Some rarely used CSS style shorthands likeunicodeBidi.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.
RemovesplitIntoSignals
method – usesplit
(see above)
Debugger
doesn't havetopoRank
field anymore (it was useless)
RemoveId[A] = A
type fromutil
– define your own if you need it
Remove hiddenRef
util class – use the newdistinct*
methods
EventStream.periodic
resetOnStop
default changed fromtrue
tofalse
in accordance with new semantics
RemovedemitInitial
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
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.
<X>Stream.scala
and<X>Signal.scala
files always next to each other when sorted alphabetically? Bliss.Modifier
type moved fromScala DOM Types to Laminar
Modifier
's generic type param is constrained now, so you can’t useModifier[_]
won't compile anymore, useModifier[_ <: ReactiveElement.Base]
instead.
No moreSingleParentObservable
. Replace withSingleParentSignal
andSingleParentStream
Splittable
now requiresempty
.IdSplittable
is removed.
RenameProtected.maxParentTopoRank
toProtected.maxTopoRank
LaminarKey
types likeReactiveProp
,ReactiveHtmlAttr
, etc. don’t extend the correspondingcom.raquo.domtypes
types anymore (those types were removed fromScala DOM Types), and also they were renamed (see "User-facing renamings" section below)
LaminarNode
types likeChildNode
don’t extend the correspondingcom.raquo.domtypes
types anymore (those types were removed fromScala 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 likenodesArrayToInserter
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 likeCombineSignal2
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 asRenderableNode
andRenderableText
, as well as simplifications like removing unneeded type params.
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)
Laminar
Fix: Prevent text cursor from jumping to the end of the input in Safari when using the controlled-input pattern without using Laminar’scontrolled
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 tocontrolled()
in reverse order too
Build: 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 ofparentVar.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: RemovedcacheInitialValue
option fromCustomSignalSource
– it did not work, so this should not change any behaviour
Migration: find and rename these as they fail to compile. Other renamings will show deprecation warnings.
Laminar
Many Laminar DOM tag names now have aTag
suffix, e.g.time
->timeTag
,main
->mainTag
,header
->headerTag
,section
->sectionTag
.
LaminarKey
types were renamed: likeReactiveProp
->HtmlProp
,ReactiveStyle
->StyleProp
,ReactiveEventProp
->EventProp
,ReactiveHtmlAttr
->HtmlAttr
,ReactiveSvgAttr
->SvgAttr
,ReactiveComplexHtmlKeys
->ComplexHtmlKeys
,ReactiveComplexSvgKeys
->ComplexSvgKeys
,CompositeProp[_]
->CompositeHtmlProp
.
ReactiveHtmlElement
andReactiveSvgElement
types keep their current names!EventListener.Any
->EventListener.Base
customHtmlAttr
,customSvgAttr
,customProp
,customStyle
renamed – see deprecation notices
content
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 theSubscription
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
The development of Laminar itself is kindly sponsored bypeople who use it in their businesses and personal projects.
GOLD sponsors supporting Laminar:
Thank you for supporting me! ❤️