tc39 / proposal-signals

A proposal to add signals to JavaScript.
MIT License
2.87k stars 54 forks source link

Way to temporarily exclude a watcher #179

Open dead-claudia opened 1 month ago

dead-claudia commented 1 month ago

So, I ran into a very niche need: I need to read a watched signal, but I need to read it repeatedly while it's dirty (related: #129) and, once it's clean, I need to then re-watch it.

For correctness reasons, I need it to not call Signal.subtle.{un,}watched hooks during this, and as it's in a very hot path, I need this very optimized.

This could be solved enough for my needs very simply: direct watcher.block() and watcher.unblock() methods. (The latter's proposed in #178.) It's just one field set, and so it's O(1) instead of O(tree depth). I do also need watcher.block() to return a boolean, so I can avoid calling watcher.unblock() prematurely (in case of recursive call).

dead-claudia commented 1 month ago

Use case dried up for me, but it'd still be nice to have regardless, since it's such a simple method to implement.

shaylew commented 1 month ago

I'm trying to understand this use case but I don't think I quite have my head around what you're (hypothetically/previously) trying to achieve. Can I try to rephrase the pieces and see if I'm making any progress getting towards the page you're on?

(And, maybe more philosophically, but I think it's germane here:)

To me the goal (which I think we haven't yet quite achieved) of the whole Watcher idea is to provide basically a "disembodied Computed": it has no value, it has no function to run, the system doesn't memoize anything for it, it has no readers downstream of it... but upstream it has the same affordances as a Computed does internally, and it tries to expose them to userspace. So it has a notion of "is it dirty" (has its notification fired), of "becoming clean again" (if you re-arm it with watch), of checking which of its dependencies might have changed (getPending / notifiedBy) and perhaps which have changed for real (this one is missing in the current API).

I think we sort have have two direction to go from there:

I don't think we have the canonical use cases or correctness criteria nailed down for Watchers yet, but -- if there's going to be such a thing -- this sort of "interface node" exposing all the relevant graph concepts we're already using to implement Computeds seems like one good start for discovering them.

dead-claudia commented 4 weeks ago

@shaylew My original use case was this: reattempt to render a subtree while it's dirty, and retry synchronously, rather than having it re-schedule against the watcher.

This can be accomplished using something like the following pattern:

const wasBlocked = watcher.isBlocked
// Keeps the watcher callback from being invoked
watcher.isBlocked = true
try {
    while (signal.isDirty) {
        renderSubtree(signal.get())
    }
} finally {
    watcher.isBlocked = wasBlocked
}

I found an alternate design not reliant on this, so my use case no longer exists. But the operation is trivial, so I'm still behind it in principle.

shaylew commented 4 weeks ago

Would you want the watcher callback to be invoked immediately when unblocked, or to just not get notifications from any dependencies dirtied while it was blocked until/unless those dependencies are cleaned and then dirtied a second time?

Watchers already have a dirty bit that tracks whether their notify fired since they were last armed, and they already don't fire again unless they were explicitly re-armed. So it really seems like someone should be able to build isBlocked on top of that, by no-oping notifications while blocked and then (if the watcher was armed when it became blocked) re-arming with .watch() when unblocked.

dead-claudia commented 3 weeks ago

Would you want the watcher callback to be invoked immediately when unblocked, or to just not get notifications from any dependencies dirtied while it was blocked until/unless those dependencies are cleaned and then dirtied a second time?

@shaylew The idea is that the watcher would not get called if dependencies change during that span. And after being unblocked, it would continue to not be called up until a watched dependency is updated, in which it'd be called as normal.

Watchers already have a dirty bit that tracks whether their notify fired since they were last armed, and they already don't fire again unless they were explicitly re-armed. So it really seems like someone should be able to build isBlocked on top of that, by no-oping notifications while blocked and then (if the watcher was armed when it became blocked) re-arming with .watch() when unblocked.

Reusing/exposing the dirty bit for that is the idea, yes. It'd also provide for a clearer story on how to re-arm the watcher after its notify scheduler executes.