raquo / Airstream

State propagation and event streams with mandatory ownership and no glitches
MIT License
246 stars 28 forks source link

.split operator is hard to use without Laminar #101

Open raquo opened 2 years ago

raquo commented 2 years ago

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:

val $listOfModels: Signal[List[Model]] = ???
def renderCallback(key: Int, initial: Model, $model: Signal[Model]): HtmlElement = {
  span(child.text <-- $model.map(_.name))
}
div(
  children <-- $listOfModels.split(_.id)(renderCallback)
)

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.