Skip to content
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: Element.observe(signal) to safely get the signal's current value. #158

Open
raquo opened this issue May 6, 2024 · 1 comment
Open

Comments

@raquo
Copy link
Owner

raquo commented May 6, 2024

Background

This is yet more rumination on the issue of lifetime evidences:

Problem

Trying to be brief this time, I want an easy and safe way to call .now() on signals, without sacrificing safety, and without complicating the architecture. The linked issues above explain why this is hard to achieve in the current design, and possible strategies for improving the situation.

Note that this problem is specific to limited-lifetime situations, e.g. all your components' code, that is linked to element mount-unmount lifetimes. For global things that never need to be destroyed, using unsafeWindowOwner is a perfectly valid strategy.

Proposed solution

// Example is a class just to show that you're not limited to functions returning a single element.

class MyTextInput(savedTextS: Signal[String], signal2: Signal[String]):

  val clickBus: EventBus[Unit] = new EventBus

  val node: Div =
    div(
      cls("MyTextInput"),
    ).observe(savedTextS, signal2):
      (thisNode, savedTextS_, signal2_) =>
        ...
        val textInputVar = Var(initial = savedTextS_.now())
        ...
        thisNode.amend(
          onClick.mapToUnit --> clickBus,
          input(
            onInput.mapToValue --> textInputVar,
            value <-- textInputVar
          )

div.observe is the new thing here. You can call this new observe method on any element, provide it 1...N signals, and provide a callback that receives this same element as well as all of these signals converted to StrictSignal, allowing you to query its current value (.now()) at any time. This signal will only update while the element is mounted. When the element is unmounted, it will stop updating, until it is mounted again.

Importantly, the render callback of this observe method ((thisNode, savedTextS_, signal2_) => ...) is called at most once – when the element is first mounted (or immediately when it is invoked, if the element is already mounted by then). This is key.

This is different from methods like onMountInsert – the callback in those methods is called every time the element is mounted – this is why we have several versions of this method like onMountBind, onMountSet, and onMountInsert – we can't just put arbitrary modifiers into a generic onMount method, because arbitrary modifiers are not necessarily idempotent.

But with this observe method, we can! We can put anything we want in thisNode.amend, or do any other things. The observe method will return the original element for easy chaining.

Observed signals' lifetime

To reiterate, the observe method's render callback is only called if and when the element is mounted. So if we create this element but never mount it, the element never starts observing the signals, and we never get access to them. So if we do have access to the signals, it means that they have some value, they can't have no value at all. This is an improvement over the peekNow() proposal which would see us face exceptions in such cases.

As a user, you could potentially allow observed signals (savedTextS_ and signal2_) to escape the scope of the render function, for other code to access it. This is not intended use, but it's possible. In that case, these signals will continue having updating their values for as long as the original element that observed them is mounted, but as mentioned before, when it is unmounted, the signals would stop updating. Unless some of your other code adds observers to them, of course.

Ergonomics

Unlike onMountInsert, the observe method returns the element itself, not an opaque Inserter type. So, it can be used inside the split method's callback, inside child <--, children <--, etc.

Unlike child-specific owners (#148), this can be used on any element, inside or outside of split.

I'm not a fan of the boilerplate (duplicating the lists of signals in the observe call's arguments), but I don't see how it can be avoided, and the benefits of the design outweigh the annoyance, I think.

Use cases

Currently this feature is exclusively about signals and getting their .now() value. I assume that raquo/Airstream#119 will obviate the need for supporting zoomed vars. I don't see other use cases, it seems to be pretty much just this one rough edge that needs fixing.

Implementation

Each Laminar element has a DynamicOwner. Calling observe would create a new DynamicSubscription that, for each provided signal, would create a special type of StrictSignal that would be updated whenever DynamicOwner is active.

I would need to check the type hierarchy of StrictSignal / ObservedSignal / OwnedSignal to see if it still makes sense, and clean up / amend as necessary.

We would need to source-generate observe methods with different arities like we do for e.g. combineWith methods.

Overall this seems like a pretty small change technically, compared to the other referenced issues.

Architecturally, this feature should have no negative impact on other planned features, it is quite self-contained.

@raquo
Copy link
Owner Author

raquo commented May 9, 2024

One variation of this could be an observe(signal1, signal2) modifier that you put in a div, combined with allowing reading of signal values with .now(). The observe would ensure that the signals that you call .now() on are started, but there would be no static type safety for that. If you forget to observe a signal that you call .now() on (but don't use in any other way), calling .now() would start the signal, and pull its value from parent signals. This may not give you the value that you want if the signal depends on streams (e.g. via flatMap or composeChanges) since the streams would have no time to run.

So, you would be able to call .now() any time, it would always "work", but in some cases could give you a stale value if you didn't observe the signal (whether using the new observe modifier, or by adding observers the usual way).

Not convinced yet that this is a good approach. I need to do more reading / thinking on this whole thing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant