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

Expose child-specific owners to help users write strict state logic #148

Open
raquo opened this issue Jan 15, 2024 · 0 comments
Open

Expose child-specific owners to help users write strict state logic #148

raquo opened this issue Jan 15, 2024 · 0 comments

Comments

@raquo
Copy link
Owner

raquo commented Jan 15, 2024

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 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 =>
     val v = Var(...)
     val zoomV = 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 =>
        val v = Var(...)
        val zoomV = v.zoom(...)(ctx.owner)
        div("this is child div", ...)
      }
    )
  }
)

This may look manageable, but it has two problems:

  1. 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>.

  2. 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 =>
        val v = Var(...)
        val zoomV = 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:

val childrenSignal = signal.map(_.id)((id, initial, childSignal, childOwner) => div(onClick --> ...)).observe(someOwner)

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:

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 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.

Alternative Laminar Solution?

Alternatively, can the following work?

child <-- signal.map(ev => onMountInsert(ctx => div(...)))

children <-- signal.split(_.id)((id, initial, childSignal) => onMountInsert(ctx => div(...)))

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.

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