You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
If you want to build Laminar components with complex state logic, you need access to an Owner. For example, to .observe() a signal, or to .zoom() on a Var. However, getting an Owner can be unnecessarily cumbersome when you need it in dynamic children. I often find myself using onMountInsert inside split render callbacks and thinking "just give me the Owner, dammit, we both know what the lifetime of this child element is".
Problem
Laminar already provides Owner instances that match the "lifetime" of every Laminar element: you can access them with ctx.owner inside the onMount* { ctx => } callbacks. Specifically, onMountInsertcan be used by child components to obtain an owner. There are good reasons why Laminar's public API is constrained this way, crucial among them the fact that a Laminar element gets a new Owner every time it's mounted.
In practice this actually works fine for simple use cases:
Static case
div(
"this is parent component div",
onMountInsert { ctx =>valv=Var(...)
valzoomV= v.zoom(...)(ctx.owner)
div(
"this is child component div that has access to its owner. ",
"it can access substate without using `<--` or composing observables: ",
zoomV.now().toString
)
}
)
Everything inside onMountInsert is re-evaluated every time the parent component is mounted into the DOM, but that's not a big deal, really. If you wanted to retain state across those re-mounts, you would just get Owner from a grand-parent element (or higher up) that doesn't get unmounted, instead of getting it from the immediate parent. Just move unMountInsert call up the component tree, and pass down the Owner you get from it. Could be annoying, but it's simple, and conveys intent well enough, for that one time that you may actually need this.
But this is only a simple case – a statically rendered child. Consider dynamic children:
Dynamic child case
div(
"this is parent div",
child <-- stream.map { ev =>
div(
onMountInsert { ctx =>valv=Var(...)
valzoomV= v.zoom(...)(ctx.owner)
div("this is child div", ...)
}
)
}
)
This may look manageable, but it has two problems:
You need to create an extra div element for each child, just so that you can call onMountInsert inside of it – it serves no other purpose, and pollutes the DOM. This could be problematic in parts of the DOM that require a strict hierarchy of special elements, such as inside <table>.
We KNOW that the code executing inside stream.map callback only executes when this stream.map(...) has listeners, which only happens when the outermost parent div is mounted. This knowledge could provide us with an Owner, but at the moment, it does not, necessitating the onMountInsert boilerplate.
children <-- works similarly, except it's often used with the .split operator, and that will need a separate implementation.
Split children case
div(
"this is parent div",
children <-- signal.split(_.id) { (id, initialV, vSignal) =>
div(
onMountInsert { ctx =>valv=Var(...)
valzoomV= v.zoom(...)(ctx.owner)
div("this is child div", ...)
}
)
}
)
This has similar problems to the simple child <-- case, except the solution needs to be specific to the split operator.
We already have a proto-solution: we provide initialV in each callback. We can only do this because we KNOW that if this callback is executed, the signal must be active, so it's safe to get its current value without risking it being stale (see #130 for further discussion).
However, this proto-solution does not actually give us a proper Owner, so at the moment we can't fully benefit from our knowledge of the signal's active state.
Desired Feel
Basically, the makeChildElement callback in children <-- signal.split(_.id)(makeChildElement) should provide you a child-specific Owner similarly to how onMountInsert(makeChildElement) provides an Owner to its own makeChildElement callback. This should eliminate the need for creating an extra div and nesting an onMountInsert inside the split callback manually.
Similarly, in child <-- signal.map(makeChildElement), the makeChildElement callback should have access to a child-specific Owner, somehow.
I don't want to have to use onMountInsert to get an Owner in cases when I know that the element I'm working with is already mounted (or I know that it will be mounted imminently). I want Laminar / Airstream to also know this, at least for the popular use cases, and provide me an Owner in a more convenient way that does not require boilerplate.
Airstream Solution
It seems that the solution should ideally be purely on the Airstream level. For example, for split, the operator already has complex custom logic tracking the child elements. We could amend that logic to 1) create Owner-s every time the operator internally calls the makeChildElement callback, and 2) kill the owner when removing a child for a certain it. And of course, the operator would need to provide this owner as an argument to the makeChildElement callback.
This seems doable, and will actually be a good improvement even disregarding the Laminar use case. See raquo/Airstream#101
One challenge with this approach is that Airstream does not have access to the Owner-s that Laminar creates for its elements. Laminar gets the elements from Airstream observables first, and mounts them afterwards. And yet, Airstream needs to provide owners that match the lifetime that is determined by Laminar. I think all of this works out well if the observables containing elements are used in the canonical way – being passed to child <-- or children <--. However, consider this:
Here, childrenSignal will be started by someOwner, Airstream will synthesize a childOwner for each child and call the (id, initial, childSignal, childOwner) => div(...) callback for each child. That would be fine, except we didn't actually render the elements anywhere – we didn't pass them to child <-- or children <-- on a parent element, we just saved them in a strict signal. And yet, the child callbacks will receive an active childOwner – its lifetime will last as long as this child is present in the signal, which is what you would expect, except you would perhaps not expect that the child element itself remains unmounted, and its onClick --> ... will not actually activate. For clicks that doesn't matter, because a state of "no clicks received" is always a valid case, but if we had someSignal --> someObserver instead, this could be an important component of your state logic.
In this case could Airstream also "activate" the child element, starting its subscriptions? No, mostly because Airstream doesn't know anything about Laminar elements, the split operator works on any type.
I'm not sure how big of a problem it is. Conventional Laminar style says you should not carry elements in observables, that you should only create elements from models at the last moment before passing them to child <-- or children <-- to avoid problems like accidentally trying to insert the same element into two different places. I forget if that's actually documented anywhere though.
The case of stream.map(makeChildElement) basically follows the same logic, except I think I'll need to create a special withOwner operator that will provide an owner whose lifetime will last from the current event until the next event, or until the stream is stopped:
A small downside of all this is that this may break long-standing split callback syntax. Migration won't be hard, but the big Laminar video has the best explanation of split, and it uses the current callback arguments. I would be sad if this important aspect of the video became incompatible with modern Laminar, and I'm not really up for re-recording it. I don't know, maybe I'll just re-record a few slides (Web Components ones are also outdated), but this just makes this ticket a bigger job.
Instead of making Airstream provide owners, we could give Laminar the ability to render observables of onMountInsert(_ => el) instead of observables of element. I'm not sure if this is possible in the general case. Laminar uses reference equality between elements in its children logic, but no such equality is really possible when the elements are wrapped in onMountInsert(...).
I haven't really thought that through, but I'm like 80% sure that it won't work out in all cases no matter how hard I try, and might require additional boilerplate such as special Laminar-specific split methods to work. A pure Airstream solution seems like a more promising approach, with a more straightforward (if complex) implementation, and a wider applicability.
The text was updated successfully, but these errors were encountered:
Background
If you want to build Laminar components with complex state logic, you need access to an
Owner
. For example, to.observe()
a signal, or to.zoom()
on a Var. However, getting anOwner
can be unnecessarily cumbersome when you need it in dynamic children. I often find myself usingonMountInsert
insidesplit
render callbacks and thinking "just give me the Owner, dammit, we both know what the lifetime of this child element is".Problem
Laminar already provides
Owner
instances that match the "lifetime" of every Laminar element: you can access them withctx.owner
inside theonMount* { ctx => }
callbacks. Specifically,onMountInsert
can be used by child components to obtain an owner. There are good reasons why Laminar's public API is constrained this way, crucial among them the fact that a Laminar element gets a new Owner every time it's mounted.In practice this actually works fine for simple use cases:
Static case
Everything inside
onMountInsert
is re-evaluated every time the parent component is mounted into the DOM, but that's not a big deal, really. If you wanted to retain state across those re-mounts, you would just getOwner
from a grand-parent element (or higher up) that doesn't get unmounted, instead of getting it from the immediate parent. Just moveunMountInsert
call up the component tree, and pass down the Owner you get from it. Could be annoying, but it's simple, and conveys intent well enough, for that one time that you may actually need this.But this is only a simple case – a statically rendered child. Consider dynamic children:
Dynamic child case
This may look manageable, but it has two problems:
You need to create an extra
div
element for each child, just so that you can callonMountInsert
inside of it – it serves no other purpose, and pollutes the DOM. This could be problematic in parts of the DOM that require a strict hierarchy of special elements, such as inside<table>
.We KNOW that the code executing inside
stream.map
callback only executes when thisstream.map(...)
has listeners, which only happens when the outermost parent div is mounted. This knowledge could provide us with anOwner
, but at the moment, it does not, necessitating theonMountInsert
boilerplate.children <--
works similarly, except it's often used with the.split
operator, and that will need a separate implementation.Split children case
This has similar problems to the simple
child <--
case, except the solution needs to be specific to thesplit
operator.We already have a proto-solution: we provide
initialV
in each callback. We can only do this because we KNOW that if this callback is executed, the signal must be active, so it's safe to get its current value without risking it being stale (see #130 for further discussion).However, this proto-solution does not actually give us a proper
Owner
, so at the moment we can't fully benefit from our knowledge of the signal's active state.Desired Feel
Basically, the
makeChildElement
callback inchildren <-- signal.split(_.id)(makeChildElement)
should provide you a child-specificOwner
similarly to howonMountInsert(makeChildElement)
provides anOwner
to its ownmakeChildElement
callback. This should eliminate the need for creating an extradiv
and nesting anonMountInsert
inside thesplit
callback manually.Similarly, in
child <-- signal.map(makeChildElement)
, themakeChildElement
callback should have access to a child-specific Owner, somehow.I don't want to have to use
onMountInsert
to get an Owner in cases when I know that the element I'm working with is already mounted (or I know that it will be mounted imminently). I want Laminar / Airstream to also know this, at least for the popular use cases, and provide me anOwner
in a more convenient way that does not require boilerplate.Airstream Solution
It seems that the solution should ideally be purely on the Airstream level. For example, for
split
, the operator already has complex custom logic tracking the child elements. We could amend that logic to 1) createOwner
-s every time the operator internally calls themakeChildElement
callback, and 2) kill the owner when removing a child for a certain it. And of course, the operator would need to provide thisowner
as an argument to themakeChildElement
callback.This seems doable, and will actually be a good improvement even disregarding the Laminar use case. See raquo/Airstream#101
One challenge with this approach is that Airstream does not have access to the Owner-s that Laminar creates for its elements. Laminar gets the elements from Airstream observables first, and mounts them afterwards. And yet, Airstream needs to provide owners that match the lifetime that is determined by Laminar. I think all of this works out well if the observables containing elements are used in the canonical way – being passed to
child <--
orchildren <--
. However, consider this:Here,
childrenSignal
will be started bysomeOwner
, Airstream will synthesize achildOwner
for each child and call the(id, initial, childSignal, childOwner) => div(...)
callback for each child. That would be fine, except we didn't actually render the elements anywhere – we didn't pass them tochild <--
orchildren <--
on a parent element, we just saved them in a strict signal. And yet, the child callbacks will receive an activechildOwner
– its lifetime will last as long as this child is present in the signal, which is what you would expect, except you would perhaps not expect that the child element itself remains unmounted, and itsonClick --> ...
will not actually activate. For clicks that doesn't matter, because a state of "no clicks received" is always a valid case, but if we hadsomeSignal --> someObserver
instead, this could be an important component of your state logic.In this case could Airstream also "activate" the child element, starting its subscriptions? No, mostly because Airstream doesn't know anything about Laminar elements, the split operator works on any type.
I'm not sure how big of a problem it is. Conventional Laminar style says you should not carry elements in observables, that you should only create elements from models at the last moment before passing them to
child <--
orchildren <--
to avoid problems like accidentally trying to insert the same element into two different places. I forget if that's actually documented anywhere though.The case of
stream.map(makeChildElement)
basically follows the same logic, except I think I'll need to create a specialwithOwner
operator that will provide an owner whose lifetime will last from the current event until the next event, or until the stream is stopped:stream.withOwner.map((ev, owner) => makeChildElement(ev, owner))
It has the same problem with
observe
.A small downside of all this is that this may break long-standing
split
callback syntax. Migration won't be hard, but the big Laminar video has the best explanation ofsplit
, and it uses the current callback arguments. I would be sad if this important aspect of the video became incompatible with modern Laminar, and I'm not really up for re-recording it. I don't know, maybe I'll just re-record a few slides (Web Components ones are also outdated), but this just makes this ticket a bigger job.Alternative Laminar Solution?
Alternatively, can the following work?
Instead of making Airstream provide owners, we could give Laminar the ability to render observables of
onMountInsert(_ => el)
instead of observables ofelement
. I'm not sure if this is possible in the general case. Laminar uses reference equality between elements in its children logic, but no such equality is really possible when the elements are wrapped inonMountInsert(...)
.I haven't really thought that through, but I'm like 80% sure that it won't work out in all cases no matter how hard I try, and might require additional boilerplate such as special Laminar-specific
split
methods to work. A pure Airstream solution seems like a more promising approach, with a more straightforward (if complex) implementation, and a wider applicability.The text was updated successfully, but these errors were encountered: