Skip to content

Propose an opt-in "unwatched"/observation lifecycle hook upstream to alien-signals #86

Description

@scottmessinger

Context

In PR #85 an earlier revision vendored a ~558-line port of alien-signals' operator layer into the kernel (packages/kernel/src/system.ts) purely to expose an unwatched callback. silo used it for signals-native fetch cancellation: cancel an in-flight fetch when a handle loses its last reactive observer. We removed the fork (it's a standing maintenance tax — must be re-ported on every alien-signals upgrade) and dropped auto-cancellation.

This issue tracks the cleaner long-term path: get the hook added to alien-signals itself, so we could rebuild signals-native cancellation without forking.

Why it's impossible today (alien-signals 3.2.1)

The "a node lost its last subscriber" event exists in exactly one place — unlink in alien-signals/system:

} else if ((dep.subs = nextSub) === undefined) {
    unwatched(dep);   // the only call site
}

unwatched is a private closure argument to createReactiveSystem({ update, notify, unwatched }). The default index.mjs passes its own unwatched (a no-op for plain signal nodes) and never re-exposes it. The published primitives (signal/computed/effect) are built on that default system.

A separate createReactiveSystem instance for "observation nodes" doesn't help: unwatched(dep) fires from whichever system's unlink removes the last subscriber, and the subscriber (a component's effect node) is created and torn down by the library's effect/unlink. Nodes don't carry their owning system; the teardown path does. So a custom unwatched only fires if you own the system that creates the effect nodes — and because effects share activeSub/cycle/queued/flush with signal/computed, owning effect means owning the whole operator layer. That's the fork.

Proposal to upstream

Add a minimal, opt-in way to observe watched→unwatched transitions on the default primitives, e.g. one of:

  • onUnwatched(node, cb): () => void — register a per-node "lost last subscriber" callback on the default system; or
  • export the default operator layer in a form that lets consumers supply their own unwatched without re-porting signal/computed/effect.

Design notes we'd want to land with it (learned from the fork):

  • Hot-path neutral — gated so code that never registers a handler pays nothing (no first-subscriber hook on link).
  • Coalesced + microtask-deferred dispatch — a node routinely loses and regains its last subscriber within one turn (e.g. a tracked() re-render re-establishing deps). Firing on the synchronous unlink thrashes; collect unwatched nodes and flush once on a microtask, re-checking observed-state at flush time.
  • Treat the callback as a hint, not a fact (a later macrotask — StrictMode remount, fast nav-back — can re-observe), so consumers defer irreversible work behind a grace timer and re-check.

The removed system.ts in PR #85's history is a working reference implementation of all of the above.

If accepted upstream

We could reintroduce silo auto-cancellation as a thin layer over the upstream hook (no fork), driving cancellation off the reactive graph's natural liveness rather than React-effect ref-counting.

Links


Filed as a "come back to it later" tracking issue; no action needed for PR #85.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions