proposal-signals icon indicating copy to clipboard operation
proposal-signals copied to clipboard

How to allow `set` in a `Computed` to trigger a recomputation synchronously (until the result is stable)

Open divdavem opened this issue 1 year ago • 8 comments

Hello, As a maintainer of the tansu signal library, I am trying (here) to re-implement it using signal-polyfill and I came across the following difference of behavior, that I would like to solve, avoiding any breaking change.

With tansu, calling set on a signal in a computed that has a dependency on that signal re-triggers the computation of the computed until the value is stable (with a fixed maximum number of iterations). For example:

import {writable, computed} from "@amadeus-it-group/tansu";
const s = writable(0);
const c = computed(() => {
  const value = s();
  if (value < 10) {
    s.set(value + 1);
  }
  return value;
});
const d = computed(() => {
  const value = s();
  if (value < 10) {
    s.set(value + 1);
  }
  return value;
});
console.log(c()); // logs: 10
console.log(c()); // logs: 10
console.log(d()); // logs: 10
console.log(d()); // logs: 10
console.log(c()); // logs: 10
console.log(d()); // logs: 10

(cf this test)

With signal-polyfill, there was apparently a different design decision:

import { Signal } from 'signal-polyfill';
const s = new Signal.State(0);
const c = new Signal.Computed(() => {
  const value = s.get();
  if (value < 10) {
    s.set(value + 1);
  }
  return value;
});
const d = new Signal.Computed(() => {
  const value = s.get();
  if (value < 10) {
    s.set(value + 1);
  }
  return value;
});
console.log(c.get()); // logs: 0
console.log(c.get()); // logs: 0
console.log(d.get()); // logs: 1
console.log(d.get()); // logs: 1
console.log(c.get()); // logs: 2
console.log(d.get()); // logs: 3

My question is simple: how may I reproduce the behavior of tansu when implementing it using signal-polyfill?

Do you think this signals proposal could change to adopt this different behavior?

Alternatively, we could probably implement this if we had some other primitives that are missing in the current specification (and I think those would be useful anyway):

  • having a way to intercept tracked reads (as suggested by @shaylew for a different issue here)
  • having a way to know whether a computed signal is dirty (i.e. one of its direct or transitive dependencies have changed since the last computation)
  • having a way to know whether a computed signal actually changed after recomputing it (taking into account its own equals function)

This way, maybe we could have a loop in our tansu computed which, after each computation, goes over all tracked reads called during the computed and checks if any of them is dirty at the end of computed. If it is the case, it means one of the (direct or transitive) dependencies changed during the call to computed and that we may need to recompute (in case those dependencies really changed).

What do you think?

divdavem avatar Jun 07 '24 13:06 divdavem

I believe that mutation during computation should be completely prohibited. What is in the computed should be a pure function.

szagi3891 avatar Jun 07 '24 13:06 szagi3891

I believe that mutation during computation should be completely prohibited. What is in the computed should be a pure function.

@szagi3891 Thank you for expressing your opinion. It is indeed a best practice to only have a pure function as the callback for computed.

However, I am against completely prohibiting calling set in computed. The Signal.Computed function described in this spec can be used to implement effects too (and effects are usually not pure functions). But the goal of this issue is not to debate whether calling set should be completely prohibited inside computed. There is this issue for that purpose

divdavem avatar Jun 07 '24 14:06 divdavem

@divdavem have you tried using untrack within the computed?

NullVoxPopuli avatar Jun 07 '24 15:06 NullVoxPopuli

@NullVoxPopuli Thank you for your answer!

@divdavem have you tried using untrack within the computed?

Do you mean around s.get()? But I would still like the computed to be recomputed again when s changes.

divdavem avatar Jun 07 '24 15:06 divdavem

how would that be implemented algorithmically?

NullVoxPopuli avatar Jun 07 '24 16:06 NullVoxPopuli

how would that be implemented algorithmically?

@NullVoxPopuli I have opened https://github.com/proposal-signals/signal-polyfill/pull/19 to show how this could be implemented.

divdavem avatar Jun 12 '24 14:06 divdavem

The current behaviour of only invalidating the computed makes sense to me, allowing set inside a computed seems fine, but I don't see benefit in automatically recomputing if a dep changes inside the computed.

If you want some kind of mutation of deps to affect the result like your example you can use untrack. Is there a use case you can provide that this solution wouldn't work for?

const c = new Signal.Computed(() => {
  Signal.subtle.untrack(() => {
    if (s.get() < 10) {
      s.set(10);
    }
  });
  return s.get();
});

robbiespeed avatar Jun 17 '24 19:06 robbiespeed

In graphs, we mainly want to run a given function as few times as possible. To collect all the dependencies it needs and execute it once. If we execute this function until the state stabilizes - the whole point of the graph loses its meaning. Imagine that you have a function that is very processor-intensive and you execute it multiple times to stabilize the state (and additionally you would run many other heavy? functions in these cycles).

neuronetio avatar May 05 '25 11:05 neuronetio