Skip to content

Kernel observation primitive (onUnobserved) → signals-native fetch cancellation in silo #83

Description

@scottmessinger

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/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 no onUnwatched 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.
export function onObservationChange(
  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:

  1. 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.
  2. 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.
  • useDocument / useQuery stay 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.
  • Update packages/silo/tests/cancellation.test.ts to drive cancellation via mount/unmount (observation) instead of manual subscribe*, 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.
  • 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).
  • kernel src/ coverage maintained; silo src/ coverage stays 100% incl. all cancellation branches.
  • 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.

Depends on

Notes

🤖 Spec generated for a follow-up implementation pass.

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