-
-
Notifications
You must be signed in to change notification settings - Fork 53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: signal.peekNow() #130
Comments
I have personally never done that kind of "trick", and certainly I don't remember that I used For what it's worth, if I had to implement the particular use case you mention myself, I would do something like def childComponent(initialValue: Int): Div =
val childVar = Var(initial = initialValue)
println(s"current number: ${childVar.now()}")
div(
"Hello world",
child.text <-- childVar
)
def parentComponent(): Div = {
val numVar = Var(0)
div(
"Child: ",
child <-- numVar.signal.map(_ + 1).take(1).map(childComponent)
)
}
val app = parentComponent() where that |
Some notes / ideas, mostly for myself We were talking with @armanbilge about this today, from the perspective of lifecycle management in observable / streaming systems. He takes different tradeoffs in Calico, and it was interesting to learn those patterns and think about their possible applications in Laminar. Very rudimentarily, one pattern we could employ in current Laminar is something like this: def component(signal1: Signal[String], signal2: Signal[B]) =
div.observed(signal1, signal2) { (s1, s2, owner, thisRef) =>
// Here s1 and s2 are strict, and `owner` is available to make derivatives like s1.map(...) also strict
List(
"Hello " + s1.now().toUpperCsae,
child <-- s2.map(...)
)
}
def observed(signal1: Signal[A], signal2: Signal[B])(render: (StrictSignal[A], StrictSignal[B], Owner, Div) => List[Modifier]) = {
onMountInsert { ctx =>
thisNode => div(inContext { thisNode => render(signal1, signal2, ctx.owner, thisNode) } )
}
}
div(
"Parent here",
component(signal1, signal2)
) As presented, this is essentially an abstraction over my More generally, we talked about Airstream's observables being lazy (as a downside – managing their lifetimes, even though it's done automatically, can still be annoying at times as evidences by this very issue), and Calico's concept of elements-as-resources that allows their observables to be strict and yet not leak memory. In Laminar you can mount and unmount a component, and this will stop and then restart the subscriptions, while retaining the same DOM element, and reviving the subscriptions in a reasonable and safe. It took me a lot of work to get to a point where this is working relatively smooth, but I think the upcoming version of Laminar nails down this pattern. Yet this pattern isn't really an end-goal, it's more of a necessity due to our observables being lazy. And the observables are lazy because otherwise they would leak in certain cases (see the linked discord thread, or #33), at least as long as we have direct element creation using I think we should investigate borrowing some wisdom from Calico's approach. That would be an immense undertaking, and would need a lot of thought put into it to even just fully understand the tradeoffs, let alone iron out all the bugs, but it's good to talk it out even if I'm not going to do it anytime soon, or at all. Consider this:
Then, we need: class Element(tag: Tag):
class Resource[A](Owner => A):
Tag:
So essentially: val user: Signal[User] = ???
val el: Resource[Element] = div.withOwner( owner =>
// if we use withOwner, we can use class-based components like TextInput and their props as usual
val nameInputVar = Var("world")
val formattedName = nameInputVar.signal.map(_.toUppercase).own(owner)
val textInput = TextInput(owner, formattedName)
textInput.inputNode.amend(
onInput.mapToValue --> nameInputVar,
value <-- nameInputVar
)
dom.console.log("Initial name: " + formattedName.now())
List(
"Hello, ",
b(child.text <-- formattedName),
textInput.node,
br(),
// onMountCallback equivalent (onMountBind and onMountInsert are not needed - just put that stuff here)
// onMountUnmountCallback is also possible with... "bimap" or whatever FP lords would call it.
div(cls := "widget").map((thisRef, owner) => setupThirdPartyComponent(thisRef.ref))
br(),
// child <-- Signal[Resource] provides a new Owner to the new child every time it emits
// that owner will die on the next event, or when the parent element's owner dies.
child <-- user.map(renderUserResource(_, formattedName)),
// If we wanted to render individual child elements, we would need to provide some
// owner, but there isn't any. the parent's owner would be unsuitable here, but that's the
// only owner available to the user. Does this mean child <-- should not accept Signal[Element]?
child <-- user.map(renderUserElement(_, formattedName), owner = ???)
// this one works just like our regular split, creating an element and an owner context
// allows for efficient rendering of user info as usual.
// do we need a similar implementation that requires a resource callback instead of element callback?
child <-- user.splitOne(_.id)(renderUserSplit(formattedName))
)
)
class TextInput(owner: Owner, formattedName: StrictSignal[String]) {
val inputNode: Element = input(cls := "input").build(owner)
val node: Element = div(
"Initial name: " + formattedName.now(),
inputEl
).build(owner)
}
def renderUserResource(user: User, formattedName: StrictSignal[String]): Resource[Element] = {
// ??? this userFormattedName still can't be strict, right?
// this can't be owned by the parent owner, because then we're leaking memory just like current Airstream would
// so we need some special syntax for this... Calico has flatMap but I'm looking for something lighter
val userFormattedName = formattedName.map(user.formatAgain)
div(
user.name + " (",
"Initial: " + userFormattedName.now(), // can't do this, need `div.withOwner ( owner =>` to make into StrictSignal
child.text <-- userFormattedName,
")"
)
}
def renderUser(user: User, formattedName: StrictSignal[String], owner: Owner): Element = {
div(
user.name + " (",
child.text <-- formattedName.map(userformat),
")"
).build(owner)
}
def renderUserSplit(id: String, userSignal: StrictSignal[User], owner: Owner)(formattedName: StrictSignal[String]): Element = {
div(
"Initial name: " + userSignal.now().name,
child.text <-- userSignal.map(_.name),
child.text <-- formattedName.map(userformat),
).build(owner)
} For now, I still don't see how to get rid of laziness without paying the boilerplate price of flatmapping (the Ask Arman later about I think (but not sure) that Calico's observable transformations like .map can only have pure functions in them, and they don't have shared execution – maybe that somehow allows for a different memory model? I would understand if it was pull-based, but it appears to be push-based, meaning that parent observable needs to have references to child observables in order to propagate events. So, pure or not, it should still have GC-preventing links in the structure. Hrm. |
Yes, that's right. Only pure functions, and responsibility for executing them actually falls to each subscriber to the signal. Mapping a signal basically only provides a "view", it does not allocate any state to cache an intermediate value.
Well, it's a bit of push-pull 🤔 subscribing to a signal returns something of Furthermore, once a signal fires an event (push), it actually clears out all of its listeners. It's up to listeners to resubscribe (pull) whenever they are interested and ready for events again. This is implemented such that if there was at least one event during that window where a listener was unsubscribed, it will be notified immediately upon re-subscription.
I guess you mean this one, used in the routing example? In that case, the Then we setup a subscription here, which is bound to the lifecycle of the As described by the push-pull dynamics above, I think I would say it's lazy? If nobody is listening to a signal (pull), then it is not firing events (push). However, it is still "alive" in the sense that you can Hope that helps :) |
Regarding this one: def renderUserResource(user: User, formattedName: StrictSignal[String]): Resource[Element] = {
// ??? this userFormattedName still can't be strict, right?
// this can't be owned by the parent owner, because then we're leaking memory just like current Airstream would
// so we need some special syntax for this... Calico has flatMap but I'm looking for something lighter
val userFormattedName = formattedName.map(user.formatAgain)
div(
user.name + " (",
"Initial: " + userFormattedName.now(), // can't do this, need `div.withOwner ( owner =>` to make into StrictSignal
child.text <-- userFormattedName,
")"
)
} Can't this be expressed something like this? def renderUserResource(user: User, formattedName: StrictSignal[String]): Resource[Element] = Resource { owner =>
val userFormattedName = formattedName.map(user.formatAgain) // use owner here ?
div(
user.name + " (",
"Initial: " + userFormattedName.now(),
child.text <-- userFormattedName,
")"
).build(owner)
} |
Great, that was my suspicion, thanks for confirming.
Right, yes, we can just wrap it in another |
Just a note to self – if we ever allow something like the peekNow() described in the original post, I will probably need to hide it behind a feature import, similar to |
Problem
Airstream's design is focused on safety, and among other safety mechanisms, we want to prevent users from accessing the current value of signals that are currently stopped, because in those cases their value could be stale or undefined.
As signals can start and stop at arbitrary times as determined by external factors (via the ownership system), this constraint can't be expressed in types.
So, by necessity, the only way the user could prove to Airstream that the signal is started, is for the user to add an observer to it – if the signal wasn't started before, it is now. To add an observer, the user needs access to an
owner
, which Laminar can provide via anyonMount*
method (for signals with a global lifespan, users can also useunsafeWindowOwner
, but we're talking about signals that belong to elements):The snippet above prints the signal's current value every time the div is mounted into the DOM. As you see, simply accessing
.now()
on a signal requires two lines of boilerplate, and picking annoying names likeobserved<existingSignalName>
. Another annoyance is that we can only do this inside onMountCallback, and that means that the scope of childVar is limited to that callback, so it's even more annoying to use that Var to render children or bind events.In this simple example, you could replace
onMountCallback
withonMountInsert
and returnchild.text <-- childVar
from there, but this does not work if your code gets more complex (e.g. if you also need to send some onClick events intochildVar
) – I mean you can still do that, but the boilerplate starts exploding.Currently, to solve that, I often let the parent component pass the
owner
to the child:This gives us access to
childVar
in the entirety of the child component, so we can pipe it tochild.text
now. However, the boilerplate remains. We can shave off some more of it by creating observed signals on the parent side:So far, this has been mostly satisfactory to me personally, although it is not perfect – using
onMountInsert
in the parent means that the child element is re-created every time the parent is unmounted and re-mounted, and also you can't get a reference to the child element outside ofonMountInsert
– this is usually not needed, but if your child component returns not a simple element, but an instance of a class that exposes special properties / streams / etc., then it will be hard for you to access those properties outside ofonMountInsert
.To reduce the number of
onMountInsert
-s, I sometimes have several layers of ancestor components pass down the sameowner
to their children – however that is getting into sketchy territory in regards to lifecycle & memory management. That ancestralowner
will only kill subscriptions when the ancestor it belongs to (some "root"-like element that is high in the hierarchy) is unmounted, and so it is only safe to use in children that are rendered statically, not dynamically. If any children insidechild <--
/children <--
use that ancestralowner
, the child's resources bound to thatowner
won't be released until the whole ancestor has been unmounted, even if thechild
in question has been unmounted long before that.Desired feel
In my ideal world, I should be able to do this without worry:
and everything should work just fine. The new
peekNow()
method would simply look at numSignal's private current value, and return it, except it should throw ifnumSignal
is currently stopped – this isn't a replacement for managing observable lifecycles after all, it's simply a way to look at some value that you know is already there.Case in point – in this particular code snippet,
peekNow()
will actually fail because whilenumVar.signal
being a Var's signal is always-started, the derivednumVar.signal.map(_ + 1)
is not started when we call peekNow() on it. In fact, it is never started in our code at all. So, peekNow() will throw. This could have been avoided if parentComponent was also usingnumVar.signal.map(_ + 1)
in its own code, but that is not the case.I am fine paying the cost of exceptions in principle, but I'm left wondering if there will be enough use cases for such a
peekNow()
method. Maybe some day I'll implement it and use it my personal projects to see how it feels.Request for comments
I would like to understand how Laminar users deal with this problem – do you feel the need to use
.observe()
often, and is it annoying? Why do you usually use .observe() – to initialize a Var with some other signal's current value (e.g. in web forms), or for something else?The text was updated successfully, but these errors were encountered: