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

Volatile sources in the computation graph

Open PsychoLlama opened this issue 1 year ago • 7 comments

Integrating with the platform inherently requires volatile functions (borrowing the term "volatile" from Excel formulas). The platform is filled with impure properties and functions that return different values when called over time. Sometimes they expose events for detecting changes. Sometimes they don't.

Here are some concrete examples:

From what I've seen, the common pattern for integrating these APIs is to synchronize them into a signal replica:

const signal = new Signal.State(location.hash);

window.onhashchange = () => {
  signal.set(location.hash);
};

This works, but it means every integration has a global listener that survives for the lifetime of the application. Consider a library of bindings like useHooks. This is a non-starter. It only grows and adds cost with every binding.

So we optimize: only subscribe to the API when the value is actually used (meaning: under observation).

const signal = new Signal.State(location.hash, {
  [Signal.subtle.watched]() {
    window.onhashchange = () => {
      signal.set(location.hash);
    };
  },

  [Signal.subtle.unwatched]() {
    window.onhashchange = null;
  },
});

The Bug: This works, but only some of the time. Values can still be read when they are not being watched. Reading an unwatched signal will give the stale value.

// Is this value correct? Who knows. Only if an observer happened to capture it.
signal.get();

Imagine observing location.hash in a component, then a click event fires an async task and navigates away. The task finishes and uses signal.get(), but since the original component is no longer observing it, the value has gone stale. The effect completes with invalid data.

While this is a consequence of The Way it Works :tm:, it leaves a lot of space for bewildering footguns. To make this robust you need to know if the value is being observed and branch, either using the signal or reading the value from source. This applies to trees of Computed sources too. It isn't clear how a framework would solve this without devolving to "observe all platform bindings, all the time, forever".

I'm not the first one to notice this. It's a recurring theme in other issues:

  • https://github.com/tc39/proposal-signals/issues/227 (attempt to bind localStorage resulting in similar issues)
  • https://github.com/tc39/proposal-signals/issues/165 (sketch of a very similar idea from dead-claudia)
  • https://github.com/tc39/proposal-signals/issues/9 (tangential, but good example of Math.random as a volatile source)

Proposal

Ultimately the challenge comes from maintaining two sources of truth: one in the platform and one in the signal. We can't keep the signal state fresh without permanently listening to the platform, and this causes different behaviors when observed and not observed. So I suggest we don't try.

Instead, I propose (bear with me) a new Signal.Voltile source that reads the value directly:

const signal = new Signal.Volatile(() => location.hash);

Every signal.get() uses the getter to pull the value. It is never stale, even when unobserved.

Unfortunately much like Excel, this has the effect of busting the cache for every computed down the chain. It's rather extreme. We can avoid it by tapping into change handlers for features that support it:

const signal = new Signal.Volatile(() => location.hash, {
  subscribe(onChange) {
    window.onhashchange = onChange;

    return () => {
      window.onhashchange = null;
    };
  },
});

In this hypothetical example, volatile signals with subscribe handlers would become non-volatile when observed (same cache semantics as signals) and revert to volatile when not observed (maintaining correctness when read outside Sauron's watchful gaze).

I think the majority of platform bindings fall under this style, as does integrating with any external store.

Adding a new primitive is rather extreme, but for the life of me I can't figure out how to reconcile this with signals. I appeal to spreadsheets because it seems they haven't solved it either. Forgive my hubris.

PsychoLlama avatar Aug 12 '24 01:08 PsychoLlama