You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Give the kernel a reactive-observation lifecycle primitive so code can run a callback when a reactive node loses its last observer (and gains its first). Then use it to make @supergrain/silo cancel an in-flight fetch automatically when no component observes a handle anymore — true signals-native cancellation, with zero useEffect/imperative subscription in useDocument/useQuery.
This replaces silo's current opt-in cancellation capability (store.subscribeDocument / store.subscribeQuery ref-counting) with cancellation that rides the reactive graph itself.
Background / why this needs core work
alien-signals 3.x was landed on the branch for PR #82 (kernel 2.0.7 → 3.2.1). While investigating, we confirmed the high-level API cannot drive this on its own:
The default reactive system does call unwatched(node) when a node loses its last subscriber, but for signals the handler body is intentionally empty, and the default system is not configurable from outside.
The high-level exports (signal/computed/effect) expose noonUnwatched hook.
The only way to observe unwatch is to own the system via createReactiveSystem(...) from alien-signals/system, supplying a custom unwatched callback.
So this is deliberately scoped as its own PR (kernel hot-path change + benchmark pass), separate from the silo Effect migration.
Part 1 — Kernel: own the reactive system
Reimplement the kernel's primitive layer on createReactiveSystem instead of importing signal/computed/effect from alien-signals directly.
Add e.g. packages/kernel/src/system.ts that calls createReactiveSystem({ update, notify, unwatched }). Port update/notify from alien-signals' default system (see node_modules/alien-signals/esm/index.mjs — ~250 LOC of operators: signal, computed, effect, effectScope, startBatch/endBatch, getActiveSub/setActiveSub, run, flush, updateComputed, updateSignal, the *Oper functions). Keep behavior identical; only the unwatched callback gains custom dispatch.
The custom unwatched(node) must preserve the default behavior (computed → dispose deps; effect/scope → dispose) and additionally invoke any registered per-node observation callbacks.
Re-point all kernel imports of signal/computed/effect/startBatch/endBatch/getActiveSub/setActiveSub from "alien-signals" to the new internal system module. Affected files: core.ts, read.ts, collections.ts, batch.ts, internal.ts, react/tracked.ts, react/for.ts, react/use-signal-effect.ts, plus the public re-exports in index.ts. Keep link/unlink/propagate/checkDirty delegated to alien-signals/system (we only own the thin operator layer, not the graph algorithm).
Public API (proposed)
// @supergrain/kernel// Register a callback fired when `node` transitions observed→unobserved// (last subscriber removed) and, optionally, unobserved→observed.// Returns an unregister function.exportfunctiononObservationChange(node: ReactiveNode,handlers: {onObserved?: ()=>void;onUnobserved?: ()=>void},): ()=>void;
Plus a way to get the ReactiveNode behind a reactive proxy property (the kernel already has per-property signal nodes via getNode/getNodes in core.ts; expose a helper to retrieve a property's node, or attach observation at the $NODE/$VERSION level).
Open design question for the implementer: silo handles are createReactive(...) proxies whose reactivity is a dynamic set of per-property signals. "Handle has no observers" = all of its property signals are unobserved. Two viable approaches:
Gate on a single dedicated liveness node per handle (e.g. the handle's $VERSION signal) that every reader necessarily subscribes to, and watch that one node's observed-state.
Track observed-count across the handle's property signals.
Prefer (1) if a single node can be guaranteed-subscribed by any real reader; it's far simpler. Document the choice.
Timing / re-entrancy
unwatched fires synchronously during unlink/propagation. Do not cancel a fetch directly inside it — defer (microtask / the existing gcTimeMs debounce) so a StrictMode remount or fast nav-back re-subscribes before the cancel fires. Reuse silo's gcTimeMs semantics.
Part 2 — Silo: rewire cancellation onto observation
Replace the manual ref-count machinery in packages/silo/src/finder.ts (subscribe/unsubscribe/the subscriber maps/gcTimers keyed by manual counts) with observation-driven cancellation: when a handle's liveness node goes unobserved, schedule the chunk interrupt (keeping the existing fiber-interrupt + AbortSignal + reset-to-idle behavior and the partial-batch rule: only cancel when every key in an in-flight chunk is unobserved).
Remove the now-redundant public subscribeDocument / subscribeQuery from the DocumentStore interface and store.ts (no consumer once cancellation is automatic), unless we want to keep them as an escape hatch — decide and document.
Update packages/silo/README.md "Cancellation" section and the silo changeset: cancellation is now automatic + signals-native (drop the "opt-in capability / revisit when the core primitive exists" framing).
Acceptance criteria
Unmounting the last component observing a handle interrupts its in-flight fetch (aborts the AbortSignal) after the gcTimeMs debounce; a surviving observer keeps it alive; partial-batch rule honored.
A quick remount within gcTimeMs does not cancel.
useDocument/useQuery contain no useEffect/imperative subscription.
All five gates green: pnpm test, pnpm run test:validate, pnpm run typecheck, pnpm lint, pnpm format (see CLAUDE.md).
No perf regression on packages/js-krauset. Per CLAUDE.md benchmarking rules: pnpm perf:stats baseline 15 on main, pnpm perf:stats optimized 15 on the branch, pnpm perf:compare baseline optimized. The new system module is on the hottest path (every proxy read/write), so this gate is mandatory — do not dismiss consistent deltas as noise.
Goal
Give the kernel a reactive-observation lifecycle primitive so code can run a callback when a reactive node loses its last observer (and gains its first). Then use it to make
@supergrain/silocancel an in-flight fetch automatically when no component observes a handle anymore — true signals-native cancellation, with zerouseEffect/imperative subscription inuseDocument/useQuery.This replaces silo's current opt-in cancellation capability (
store.subscribeDocument/store.subscribeQueryref-counting) with cancellation that rides the reactive graph itself.Background / why this needs core work
alien-signals3.x was landed on the branch for PR #82 (kernel2.0.7 → 3.2.1). While investigating, we confirmed the high-level API cannot drive this on its own:unwatched(node)when a node loses its last subscriber, but for signals the handler body is intentionally empty, and the default system is not configurable from outside.signal/computed/effect) expose noonUnwatchedhook.createReactiveSystem(...)fromalien-signals/system, supplying a customunwatchedcallback.So this is deliberately scoped as its own PR (kernel hot-path change + benchmark pass), separate from the silo Effect migration.
Part 1 — Kernel: own the reactive system
Reimplement the kernel's primitive layer on
createReactiveSysteminstead of importingsignal/computed/effectfromalien-signalsdirectly.packages/kernel/src/system.tsthat callscreateReactiveSystem({ update, notify, unwatched }). Portupdate/notifyfrom alien-signals' default system (seenode_modules/alien-signals/esm/index.mjs— ~250 LOC of operators:signal,computed,effect,effectScope,startBatch/endBatch,getActiveSub/setActiveSub,run,flush,updateComputed,updateSignal, the*Operfunctions). Keep behavior identical; only theunwatchedcallback gains custom dispatch.unwatched(node)must preserve the default behavior (computed → dispose deps; effect/scope → dispose) and additionally invoke any registered per-node observation callbacks.signal/computed/effect/startBatch/endBatch/getActiveSub/setActiveSubfrom"alien-signals"to the new internal system module. Affected files:core.ts,read.ts,collections.ts,batch.ts,internal.ts,react/tracked.ts,react/for.ts,react/use-signal-effect.ts, plus the public re-exports inindex.ts. Keeplink/unlink/propagate/checkDirtydelegated toalien-signals/system(we only own the thin operator layer, not the graph algorithm).Public API (proposed)
Plus a way to get the
ReactiveNodebehind a reactive proxy property (the kernel already has per-property signal nodes viagetNode/getNodesincore.ts; expose a helper to retrieve a property's node, or attach observation at the$NODE/$VERSIONlevel).Open design question for the implementer: silo handles are
createReactive(...)proxies whose reactivity is a dynamic set of per-property signals. "Handle has no observers" = all of its property signals are unobserved. Two viable approaches:$VERSIONsignal) that every reader necessarily subscribes to, and watch that one node's observed-state.Prefer (1) if a single node can be guaranteed-subscribed by any real reader; it's far simpler. Document the choice.
Timing / re-entrancy
unwatchedfires synchronously during unlink/propagation. Do not cancel a fetch directly inside it — defer (microtask / the existinggcTimeMsdebounce) so a StrictMode remount or fast nav-back re-subscribes before the cancel fires. Reuse silo'sgcTimeMssemantics.Part 2 — Silo: rewire cancellation onto observation
packages/silo/src/finder.ts(subscribe/unsubscribe/the subscriber maps/gcTimerskeyed by manual counts) with observation-driven cancellation: when a handle's liveness node goes unobserved, schedule the chunk interrupt (keeping the existing fiber-interrupt +AbortSignal+ reset-to-idle behavior and the partial-batch rule: only cancel when every key in an in-flight chunk is unobserved).subscribeDocument/subscribeQueryfrom theDocumentStoreinterface andstore.ts(no consumer once cancellation is automatic), unless we want to keep them as an escape hatch — decide and document.useDocument/useQuerystay pure reactive reads (already shipped in Migrate @supergrain/silo network/async layer to Effect + upgrade reactive core to alien-signals 3.x #82):return store.find(...)/return store.findQuery(...). No new effects.packages/silo/tests/cancellation.test.tsto drive cancellation via mount/unmount (observation) instead of manualsubscribe*, and re-add React-level coverage (the hook-driven cancellation tests removed in Migrate @supergrain/silo network/async layer to Effect + upgrade reactive core to alien-signals 3.x #82) now that unmount auto-cancels.packages/silo/README.md"Cancellation" section and the silo changeset: cancellation is now automatic + signals-native (drop the "opt-in capability / revisit when the core primitive exists" framing).Acceptance criteria
AbortSignal) after thegcTimeMsdebounce; a surviving observer keeps it alive; partial-batch rule honored.gcTimeMsdoes not cancel.useDocument/useQuerycontain nouseEffect/imperative subscription.pnpm test,pnpm run test:validate,pnpm run typecheck,pnpm lint,pnpm format(seeCLAUDE.md).src/coverage maintained; silosrc/coverage stays 100% incl. all cancellation branches.packages/js-krauset. PerCLAUDE.mdbenchmarking rules:pnpm perf:stats baseline 15onmain,pnpm perf:stats optimized 15on the branch,pnpm perf:compare baseline optimized. The new system module is on the hottest path (every proxy read/write), so this gate is mandatory — do not dismiss consistent deltas as noise.Depends on
Notes
effect(fn)already treatsfn's return as a cleanup function, anduseSignalEffectnow wires that through — both landed in Migrate @supergrain/silo network/async layer to Effect + upgrade reactive core to alien-signals 3.x #82. The observation primitive is the missing piece for unwatch detection, which effect-cleanup alone doesn't provide.🤖 Spec generated for a follow-up implementation pass.