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.
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 anunwatchedcallback. 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 —
unlinkinalien-signals/system:unwatchedis a private closure argument tocreateReactiveSystem({ update, notify, unwatched }). The defaultindex.mjspasses its ownunwatched(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
createReactiveSysteminstance for "observation nodes" doesn't help:unwatched(dep)fires from whichever system'sunlinkremoves the last subscriber, and the subscriber (a component'seffectnode) is created and torn down by the library'seffect/unlink. Nodes don't carry their owning system; the teardown path does. So a customunwatchedonly fires if you own the system that creates the effect nodes — and because effects shareactiveSub/cycle/queued/flushwithsignal/computed, owningeffectmeans 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; orunwatchedwithout re-portingsignal/computed/effect.Design notes we'd want to land with it (learned from the fork):
link).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.The removed
system.tsin 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
node_modules/alien-signals/esm/system.mjs(unlink→unwatched) andesm/index.mjs(default operator layer)Filed as a "come back to it later" tracking issue; no action needed for PR #85.