This is based on the upcoming 15.0.0 code, but I'm pretty sure the issue applies just as well in all prior versions.
This complex implementation detail is kinda hard to explain sufficiently well, so I'm writing this mostly as a TODO for myself.
If you did find the title of this issue to be an obstacle for yourself, please do let me know.
The split operator creates child signals for every new key it encounters. Complex implementation details aside, they are conceptually similar to parentSignal.composeChanges(_.collect(fn)), where fn looks for memoized output associated with the key that this signal is bound to, and returns that.
In Laminar, you typically use split to render lists of children, so you can have code like this:
Here, we use data from the child signal ($model) to render the name for each model, updating it without rebuilding the DOM. Laminar manages all of the <-- subscriptions with custom DynamicOwner logic, where it activates Subscription-s only when the element to which <-- is bound is mounted into the DOM, and kills them when the element is removed from the DOM.
That logic is exactly what's causing the child.text <-- $model.map(_.name) subscription to be killed when the key it is tied to stops appearing in $model. The problem with that is now obvious – that logic lives in Laminar, not in Airstream. In pure Airstream, you will find it exceptionally hard to subscribe to the $model signal in a way that would safely dispose of the subscription once the corresponding key is removed from the $listOfModels signal – user code in renderCallback is simply not notified of that, nor is it provided an Owner that would be safe to use for custom subscriptions.
$listOfModels.split(_.id)((key: Int, initial: Model, $model: Signal[Model]) => {
$model.foreach(println _)(owner = ???) // Without Laminar, you don't have a suitable key-specific Owner to use here
}
I only realized this from testing new split features, and I'm not really surprised that nobody has complained so far – I doubt Airstream sees much use without Laminar, since by Airstream's design you want a consuming library to manage ownership in a boilerplate-free way. So, I'm not particularly in a rush to fix this, but this issue needs to be documented somehow, so here it is.
Some day I'd like to fix this. I'm not sure how to do it well though. Perhaps when if eventually implement observable completion (#23), the split operator could complete the child signal it created when removing the key associated with it. That would actually be pretty easy, just need to track the child signal in memoized in a tuple together with Output, or something like that.
Even without observable completion, I guess we could also implement custom logic inside SplitChildSignal that would stop the signal and permanently disable it, preventing new observers from restarting it. This would be very non-standard behaviour for Airstream though.
Both of these solutions assume that you have access to an Owner in the parent scope – one that will kill the whole stream.split(...)(...) structure when its result is no longer needed, and that you will use that same Owner for each $model. This is an acceptable assumption to me, but alternatively, Airstream could potentially provide a special Owner as the fourth argument in the project callback, however I don't like this because you don't need this owner in Laminar, and that owner existing will just confuse people.
The
split
operator creates child signals for every newkey
it encounters. Complex implementation details aside, they are conceptually similar toparentSignal.composeChanges(_.collect(fn))
, wherefn
looks for memoized output associated with thekey
that this signal is bound to, and returns that.In Laminar, you typically use
split
to render lists of children, so you can have code like this:Here, we use data from the child signal (
$model
) to render the name for each model, updating it without rebuilding the DOM. Laminar manages all of the<--
subscriptions with customDynamicOwner
logic, where it activatesSubscription
-s only when the element to which<--
is bound is mounted into the DOM, and kills them when the element is removed from the DOM.That logic is exactly what's causing the
child.text <-- $model.map(_.name)
subscription to be killed when thekey
it is tied to stops appearing in$model
. The problem with that is now obvious – that logic lives in Laminar, not in Airstream. In pure Airstream, you will find it exceptionally hard to subscribe to the$model
signal in a way that would safely dispose of the subscription once the correspondingkey
is removed from the$listOfModels
signal – user code inrenderCallback
is simply not notified of that, nor is it provided anOwner
that would be safe to use for custom subscriptions.I only realized this from testing new
split
features, and I'm not really surprised that nobody has complained so far – I doubt Airstream sees much use without Laminar, since by Airstream's design you want a consuming library to manage ownership in a boilerplate-free way. So, I'm not particularly in a rush to fix this, but this issue needs to be documented somehow, so here it is.Some day I'd like to fix this. I'm not sure how to do it well though. Perhaps when if eventually implement observable completion (#23), the
split
operator couldcomplete
the child signal it created when removing the key associated with it. That would actually be pretty easy, just need to track the child signal inmemoized
in a tuple together withOutput
, or something like that.Even without observable completion, I guess we could also implement custom logic inside
SplitChildSignal
that would stop the signal and permanently disable it, preventing new observers from restarting it. This would be very non-standard behaviour for Airstream though.Both of these solutions assume that you have access to an
Owner
in the parent scope – one that will kill the wholestream.split(...)(...)
structure when its result is no longer needed, and that you will use that same Owner for each$model
. This is an acceptable assumption to me, but alternatively, Airstream could potentially provide a specialOwner
as the fourth argument in theproject
callback, however I don't like this because you don't need this owner in Laminar, and that owner existing will just confuse people.