Coder Social home page Coder Social logo

RFC: signal.peekNow() about laminar HOT 6 OPEN

raquo avatar raquo commented on June 2, 2024
RFC: signal.peekNow()

from laminar.

Comments (6)

sherpal avatar sherpal commented on June 2, 2024 1

I have personally never done that kind of "trick", and certainly I don't remember that I used observe in the past.

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 take(1) would be an extension method that I would need to find how to implement...

from laminar.

raquo avatar raquo commented on June 2, 2024

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 onMountInsert approach above. Laminar and Airstream lack the necessary properties to make this pattern more useful.

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 div().

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:

  • If Laminar elements were lazy and had no "initial value", signals inside of those elements could evaluate their initial value strictly. We can't have both elements and signals strictly evaluated (because #33), so we're switching one from the other.
  • As Arman said, dealing with lifecycles of observables (and resources in general) is a bigger problem than dealing with indirect DOM element references, so this might be a good tradeoff.

Then, we need:

class Element(tag: Tag):

  • Same as today, 1-to-1 link to real DOM element
  • Has a single owner (provided by the parent) (instead of a dynamicOwner that creates multiple owner-s)
  • Subscriptions in this element use this element's owner
  • Thus, subscriptions are initialized immediately upon element creation, NOT when it's mounted
  • And the subscriptions are killed when the owner decides, not when this element is mounted.

class Resource[A](Owner => A):

  • Call .build() to create an Owner and produce an A from it.
  • For example, you can build an element this way.

Tag:

  • div(mods) creates a Resource[Element](owner => div.create(owner, mods))
  • div.withOwner(owner => mods) is the same but gives access to the owner if needed
  • // div.fromOwner(owner)(mods) creates an Element(!) by creating a Resource and calling build(owner) on it

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 div.observed I started with) to bind derived observables to one of the child elements / resources. I don't like this syntax very much because it breaks the regular freeform-style that we currently enjoy in Laminar – we don't force you to define resources in a certain location or in a certain order. I guess calling .own() is slightly better visually, but then, we're stuck with strictSignal.map(foo) needing to be lazy. And if it remains lazy (until we call .own()), how is that fundamentally different from our lazy signals and calling .observe(owner) on them to get a strict signal?

Ask Arman later about helloCounter.map(_.toString) in calico example code – is that lazy or strict? And if that's strict, would it cause a memory leak if it was passed to a dynamic child resource? (screw that wording, give a specific example).

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.

from laminar.

armanbilge avatar armanbilge commented on June 2, 2024

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?

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.

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.

Well, it's a bit of push-pull 🤔 subscribing to a signal returns something of Resource-like shape. That means the listener is responsible for managing the lifecycle of its subscription (typically by binding this to the lifecycle of the element its being used with.

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.

Ask Arman later about helloCounter.map(_.toString) in calico example code – is that lazy or strict?

I guess you mean this one, used in the routing example?

https://github.com/armanbilge/calico/blob/7adad4b3a61e1d701b596a791ee473eb71b581e7/docs/router.md?plain=1#L45

In that case, the SignallingRef is created when the entire app is created.

Then we setup a subscription here, which is bound to the lifecycle of the p(...) (which itself is bound to all its parents).

https://github.com/armanbilge/calico/blob/7adad4b3a61e1d701b596a791ee473eb71b581e7/docs/router.md?plain=1#L79-L80

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 .get (read peakNow()) it at any time to get its current value. If there are any transformations e.g. .map(_.toString) these would be applied on-demand.

Hope that helps :)

from laminar.

armanbilge avatar armanbilge commented on June 2, 2024

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)
}

from laminar.

raquo avatar raquo commented on June 2, 2024

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 .get (read peakNow()) it at any time to get its current value. If there are any transformations e.g. .map(_.toString) these would be applied on-demand.

Great, that was my suspicion, thanks for confirming.

Can't this be expressed something like this?

Right, yes, we can just wrap it in another Resource, makes sense, thanks!

from laminar.

raquo avatar raquo commented on June 2, 2024

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 unitArrows in Laminar 15. I still see people wanting to use .now() on signals where they shouldn't, and don't want to make it too easy to go down the wrong path.

from laminar.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.