adamhaile / S

S.js - Simple, Clean, Fast Reactive Programming in Javascript
MIT License
1.31k stars 68 forks source link

Conditionally triggering downstream computations #26

Closed visj closed 3 years ago

visj commented 5 years ago

Hey Adam!

I've been using your projects for a while now and found myself in a similar situation as the one @ryansolid described about conditionally rendering HTML elements (https://github.com/adamhaile/S/issues/21).

I just wanted to let you know that I've created a fork of S 1, and when I hit Ryans issue I added a new function to S, S.track.

What it does is that it adds the same capacity to a ComputationNode to behave like S.value, e.g. downstream computations are only reevaluated once the value of their source changes. I still need to add more tests to ensure that it actually works, and it would be great to hear your thoughts about this functionality (I'm still pretty new to reactive programming so if you have some edge cases where you think you can break it that would be really helpful, I'm struggling a bit to write proper specs).

Here is a simple example (I've changed some syntax in my own version, but I use yours here to illustrate how it works):

S.root(() => {
  let count = S.data(6);
  let onCountChange = S.track(() => {
    return count() > 5;
  })
  S(() => {
    console.log('Evaluating');
    if (onCountChange()) {
      S(() => {
        console.log('Tracked ' + count() + ' times.');
      });
    } else {
      console.log('Too low');
    }
  });
  count(7); // Tracked 7 times 
  count(4); // Evaluating. Too low.
});

Basically how it works is that it adds another state to ComputationNodes: PENDING, and then for tracking, instead of traversing through Log marking nodes STALE, we traverse nodes incrementing a pending counter, then after evaluating their sourced Stale node, either setting them as NotPending, or flagging them as Stale. This way, changes still propagate in correct order as every Computation is flagged for incoming changes, but not always evaluated.

I guess you could get this functionality wrapping computations in another DataSignal, but then they wouldn't respond until next tick.

I thought maybe this could be useful for Enumerable computations in S-array, as you could wrap computations in S.track, only triggering chained computations once the sequence changes.

1: I started out forking it to add type information for Closure Compiler, then found it so useful I've started extending it.

adamhaile commented 5 years ago

Early on with S, when I was still playing around with the algorithm, I had something like this, that incremented/decremented a dirty counter. A couple things I remember from the time that made it a bit tricky:

So it gets tricky. It was enough that I decided to explore subclocks as a solution instead.

visj commented 5 years ago

Thanks for your input! I think I'm on the right track then, I recognized the cases of sources, a pending owner (where a node is pending disposal), and applying upstream computations when accessing a pending computation.

I started out adding a ref to the Log class, but I thought it was easier to keep track of slots instead, e.g. each computation node has an owner slot and dependent slots. In other words, it mimics how you implemented sourceslots but instead tracks nodes in the RootClock update queue. I thought it was easier to implement it this way, but the downside is that it is in no way compatible with subclocks.

But in my (possibly biased after building this...) opinion, between these two, I think there are more use cases for S.track than S.subclock. Some examples where I'm using it:

  1. Adding a synchronous computation node index in SArray for map (solves the index issue described here https://github.com/adamhaile/surplus/issues/64), removing the need to track indices in a separate mapSample loop.
  2. Stopping propagation of chained array selectors. Use cases like array.find, which only triggers dependent computations once the found value changes.
  3. Optimising conditional rendering. Instead of running a big if statement, you can wrap it in S.track to keep track of when it changes, which I wrote about above (https://github.com/adamhaile/S/issues/21).

I think the most important aspect of it is how it allows you to avoid running unnecessary code. There is an overhead using track compared to S(), but I think that in many cases the functions being tracked are more often the bottle neck than the cost of building the dependency graph. I haven't used subclocks excessively, but I get the feeling they're most often useful for computations setting signals, which in my opinion is more of an edge case than those I described above. Just my two cents.

I'm maintaining this as a separate fork of S, tinkering around with some ideas. Just wanted to let you know and see if you thought it was interesting, it's not too much work moving what I've written to a pull request in that case. Otherwise I'll close this. Cheers!

visj commented 3 years ago

Hey again Adam. Just wanted to let you know that I moved my work into a new repository that builds upon your work but explores the conditional branching logic as a solution instead. I merged your ideas of S and S-array into one repo as well.

Tracking logic is especially powerful for array operations, as many can stop propagation unless changed, such as every, find, indexOf etc.

I've also explored using mutation propagation for array methods, which allows turning many array operations from O(n) to O(1).

Just wanted to let you know as it is based on your great work!