proposal-signals
proposal-signals copied to clipboard
Move from getPending to notifyBy semantics for watchers
In the current polyfill, if you put a Signal.State in a Signal.subtle.Watcher, then that state can trigger the Watcher's callback to notify, but it will never be in the getPending() set, since state signals are never dirty--there's never any extra computation to do with them.
At the same time, there's a usage mode for computed signals where their value represents the "pure" part of their computation, and then the watcher is responsible for scheduling and performing the "impure" sync to the DOM. To get this functionality for states, you have to wrap them in a computed, requiring an extra allocation.
However, we could define a notion of dirtiness for state signals: A state is dirty when it is set, but never read. For a state signal which is only read due to finding its presence in getPending(), this could be a convenient way to trigger these impure DOM sync's. (Remember, we're OK with this only for computeds which have a similar property of being only ever read by a watcher--the same property.)
Should we define dirtiness for state signals as described here? Or, if we don't do that, should we prohibit watching state signals, since they will never show up in getPending?
I wonder if this means getPending isn't quite the right primitive to offer. In addition to not working nicely with States, it also has this slightly odd nonlocal behavior where a Computed in the pending set to multiple Watchers is likely to disappear from some of their pending sets before they all get a chance to notice it.
I don't fully recall the constraints that led to getPending's particularities, but is it possible that we'd be better off with a notifiedBy() method that returned the full set of dependencies that could have led to the Watcher being notified since those dependencies were watched? This works for states, and it behaves more predictably when Watchers share overlapping dependencies.
@shaylew and I discussed this and concluded that Shay's notifiedBy() semantics are better than getPending. IMO this call should also trigger the watcher to be turned back on (otherwise it'd create a new weird kind of queueing which you should just implement in your notify callback if you want it). Let's change the README and polyfill accordingly.
while playing around with jotai and its store impl I ended up identifying 3 states for signals:
- (computed only) dirty: needs to be recomputed on next read
- (computed only) pending: on next read needs to pull upstream first and check for changes before deciding on recompute or use cached value
- changed: signal was changed since last watcher execution (this is probably kept in the watcher)
I just hit the issue with state signals never showing up in getPending() when trying to build a MobX-like library that allows tracking an object's fields and getting a notification that includes which field changed:
declare const watchObject: (target: object, callback: (field: PropertyKey) => void) => void;
class MyObject {
@state() accessor foo;
}
const o = new MyObject();
watchObject(o, (field) => console.log(`${field} changed`);
The only way to do this currently is to create a separate watcher per signal.
However, we could define a notion of dirtiness for state signals
I'm not sure this would be sufficient for these types of use-cases, since the dirty bit would be shared among watchers. If you need to know which signals changed for a particular watcher, the dirty bit needs to be stored with the watcher, not the signal. Otherwise we're still stuck at needing a watcher per signal.
edit: Sorry, I believe this is exactly what @shaylew is talking about. notifiedBy() would allow a single watcher to dispatch to many effects that are grouped by signal dependencies by keeping a map from signal->effect.