OlivierBlanvillain / monadic-html

Tiny DOM binding library for Scala.js
https://olivierblanvillain.github.io/monadic-html/examples/
MIT License
225 stars 24 forks source link

TodoMVC with imitate (WIP) #70

Closed bbarker closed 2 years ago

bbarker commented 7 years ago

Supersedes #53

Work has been done to create a ComponentList based on Map instead of List, which should improve performance somewhat, as well as improving organization of code.

There are a few major issues at this time that I'm unsure of how to debug:

  1. Slows down greatly after adding a few items (10 should definitely do it). This may be relate to (2) below, which should probably be fixed first.
  2. There appear to be too many executions of some of some Rx's before reaching a steady state, despite ample use of dropRepeats - not sure of the best way to debug this, though I wonder if it may be a referential vs structural equality issue. To see this, filter out console logs of 'yield' in a browser (for example).
  3. If we try to check off an item, we can see that we get e.g. Some(UpdateEvent(Todo(asdf,false),Todo(asdf,true))) will be shown in the console, however, it doesn't actually become checked off in the model; this can be seen by the fact that the first Todo argument of UpdateEvent never changes no matter how many times it is clicked - it is always false. Maybe this is a simple logic error and I can't see the tree for the forrest, but I suspect something more difficult along the lines of Rx propagation not happening cyclically where I assume it should be, or similarly, Rx's may not be subscribed appropriately to one another somewhere along the way.
bbarker commented 7 years ago

Note I'm still trying for the "Component of List of Components" approach, which implies an Rx embedding a list of Rxs - it seems to be a fairly tricky problem, but I'm hoping monadic-html may be able to overcome this problem and make modularity of components relatively easy compared to other FRP frameworks.

bbarker commented 7 years ago

Rather than implementing a function of a different name, should we just implement our own optimized flatten, instead of relying on Cats's flatten?

bbarker commented 7 years ago

Well, I went with merge in #72 to keep a consistent style and avoid possible conflicts with cats.

bbarker commented 7 years ago

I added Semigroup.combineAllOption which seems to simplify things, however performance is still an issue: I think there are a few redundant executions I'm having trouble spotting, despite liberally using dropRepeats.

For instance, upon loading the page, we can see most printlns are printed three times. As an example, we can see (TodoList(All,#/,DropRep(Var(List()))),List(),None) shows up three times upon initial page load ( source line ). This is a bit mysterious to me since the map call is immediately preceded by dropRepeats, so I wonder if it is an issue of quality on TLCMonitors. However, I doubt this is the case, since we see other things being executed three times as well.

OlivierBlanvillain commented 7 years ago

I think the next step would be to turn the example into a test and minimize it to understand what is the source of the redundant executions...

bbarker commented 7 years ago

Thanks for the suggestion, much better than just clicking around. I got it from 3 executions to 2 executions here.

The reasons for this seem to be that allTodos are indirectly being used elsewhere, so there is no need to include its execution separately under val todoapp: Node = .... Probably wouldn't have written it that way now but it was a bit of code smell from earlier days of my attempt in the todomvc project.

I also bring this up at the time because I seem to like the general style of using imitate in this style where we only have two linked Rx's, instead of 3.

Finally, as a side note, I increased the stack size for the compiler in .sbtopts due to scala.js crashing: https://github.com/scalatest/scalatest/issues/1164

I'll keep poking around trying to find the last redundant execution.

bbarker commented 7 years ago

I found the other culprit, though I'm not sure how to best deal with it just yet:


      val mainSection: Component[List[UpdateEvent]] = {
        val todoUpdates = Var[List[UpdateEvent]](Nil)

        val display = allTodos.map(todos => if (todos.isEmpty) "none" else "")

        val mainDiv =
          <section class="main" style:display={display}>
            <ul class="todo-list">
              { todoListComponents.view }
            </ul>
          </section>
        Component(mainDiv, todoUpdates.dropRepeats)
      }

In this snippet, both the inclusion of {display} and the inclusion of { todoListComponents.view } cause separate firings of "the main Rx cycle"; todoListComponents and allTodos are intertwined - I'm most interested if there is a general solution to this problem that isn't specific to the todomvc example.

bbarker commented 7 years ago

@OlivierBlanvillain I recall you mentioning that you had a major insight about imitate on gitter as it relates to this PR - just wondering if you could expand on that here - thanks!

OlivierBlanvillain commented 7 years ago

Hey, sorry for the long silence... My insight on imitate turned out to be incorrect, I couldn't find anything wrong with the current behavior of imitate.

My current assumption is that all the duplication you see is due to streams being "interpreted" more than once when they are attached to several mount point in the DOM. I see two possible solutions to this problem.

  1. Give control to the user over what to share and what to not share between several interpretation of a same stream. Something like a .shared which "fires" the underlying stream and returns a new Rx with a guarantee that all computations before that point are shared between all interpretations. I don't really like this solution as it forces users to think in term of mutations and events in a system that is otherwise pure and RT...

  2. Implement a optimizing interpreter that automatically inserts .shared when it doesn't break semantics (without changing anything to the public API). The idea start from the observation that certain streams (or chunks of streams), can be systematically cached and shared under certain purity assumptions. For instance, given val out = source.map(f1).map(f2), and assuming purity of f1 and f2, we know that the following two programs are equivalent (and it should be possible to prove this equivalence):

    val out = source.map(f1).map(f2)
    out.impure.foreach(println)
    out.impure.foreach(println)
    val out = source.map(f1).map(f2)
    val tmp = Var(null)
    out.impure.foreach(tmp.:=)
    
    tmp.impure.foreach(println)
    tmp.impure.foreach(println)
    // Additional care must be taken to generate proper Cancelables...

    But this is obviously not true in the general case, for instance in the presence of foldp. The assumption with this second solution is that given a sufficiently smart optimizer we could get all the benefits of the first solution without requiring any user knowledge.

    Developing a bit on this idea, it's possible to categorize the current API in three categories, always sharable, tail sharable and never sharable:

    • always sharable = map, zip, sampleOn, imitate, dropRepeats
    • tail sharable = keepIf, merge
    • never sharable = foldp, flatMap

    (Note: I think flatMap can be made more precise by saying that it "inherits" the shareability of whatever is returned by it's function. For instance, flatMapping on something that always returns a Rx made of map and zip results in something always sharable.)

    Tail sharable means that a stream can be shared after it emitted it's second element. At first glance it might not be obvious why keepIf is only tail sharable, here is a small diagram to illustrate (where "•" represents the registration point):

    val numbers: Rx[Int]
    val even: Rx[Int] = numbers.dropIf(_ % 2 == 0)(-1)
    // numbers =>    0       0   3   4        5   6 ...
    // even1   =>       •-1      3            5     ...
    // even2   =>                       •-1   5     ...

    even1 emits -1, 3, 5 while even2 emits -1, 5, so not everything can be shared between the two streams. In other words, we cannot simply implement even2 by "hooking" into even1 upon registration, but we could do that after the 5 is emitted! A similar argument we can be used to show that merge is not always sharable but only tail sharable.

    It's then interesting to think how this sharability properties compose. I have the intuition than if a Rx only compose of always sharable parts is itself always sharable, while containing any never sharable component makes the entire Rx non sharable. It gets funny when mixing always sharable and tail sharable components, it seams that the result is not just tail sharable, but something stronger where each of the intermediate streams have emitted at least one element. As an example, consider val out = rx1.dropIf(_ % 2 == 0)(-1).merge(rx2.dropIf(_ % 2 == 0)(-1)). It is possible to have out emit several values without reaching sharability, or to have each "leaf" Var to emit several values without reaching sharability (the diagrams get a bit messy to draw, but I have have them on a white board :P).