Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/kernel-observation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@supergrain/kernel": minor
---

Add a reactive-observation lifecycle primitive and own the reactive system.

The kernel now owns its primitive layer (`signal` / `computed` / `effect` / `batch`) on top of `alien-signals/system`'s `createReactiveSystem(...)` instead of importing the high-level operators from `alien-signals` directly. The graph algorithm (`link` / `unlink` / `propagate` / `checkDirty`) is still delegated to alien-signals — only the thin operator layer is owned, so the kernel can observe when a reactive node loses its last subscriber. All reactive semantics (fine-grained tracking, batching, Map/Set coalescing, `effect` cleanup) are unchanged.

**New: `onObservationChange`.** Register a callback fired when a reactive node loses its last subscriber:

```ts
import { onObservationChange, getObservationNode } from "@supergrain/kernel";

const node = getObservationNode(reactiveProxy); // dedicated, never-written liveness node
const unregister = onObservationChange(node, {
onUnobserved: () => scheduleCleanup(), // defer destructive work; re-check isObserved later
});
```

`onUnobserved` is **not** fired on the synchronous unlink: nodes that lose their last subscriber are coalesced and flushed on a microtask, and each is re-checked, so a node unobserved-then-re-observed within the same turn (a `tracked()` re-render re-establishing its dependencies) fires nothing — no thrash. `getObservationNode(proxy)` returns a proxy's dedicated liveness node (created lazily, never written, so it never causes a re-render; stable even for frozen targets). The sharp tools `trackNode` (subscribe the active sub) and `isObserved` are available from `@supergrain/kernel/internal`. There is no first-subscriber hook, so the hot `link` path is untouched; the `unwatched` bookkeeping is gated behind a counter that stays `0` until a handler is registered.

`@supergrain/silo` uses this to cancel an in-flight fetch automatically when no component observes a handle anymore — no `useEffect`, no manual `subscribe*`.
11 changes: 11 additions & 0 deletions .changeset/silo-auto-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@supergrain/silo": minor
---

Fetch cancellation is now automatic and signals-native — it rides the reactive graph instead of manual ref-counting.

Every handle carries a dedicated reactive liveness node; reading a handle through `find` / `findQuery` subscribes the rendering component to it (via the kernel's new `onObservationChange` primitive). When the **last** component observing a handle unmounts, its in-flight fetch is interrupted — aborting the request's `AbortSignal` — after the `gcTimeMs` grace window, and the handle resets to idle so renewed interest refetches. A batch is only cancelled when the last observer for **every** key in it goes away.

`useDocument` / `useQuery` remain **pure reactive reads** — no `useEffect`, no imperative subscription — and now drive cancellation automatically on unmount. The transient unobserve/re-observe of a `tracked()` re-render never cancels: the kernel coalesces and re-checks observation on a microtask, and `gcTimeMs` (default `0` = next tick) plus an `isObserved` re-check at sweep time absorb a StrictMode remount or fast nav-back.

**Removed** the opt-in `store.subscribeDocument` / `store.subscribeQuery` capability (added in the same major and never released): cancellation no longer needs a manual subscription. Adapters still receive `find(ids, { signal })` — thread it into `fetch(url, { signal })` for a real network abort, or ignore it and interruption just discards the result.
3 changes: 1 addition & 2 deletions packages/husk/src/resource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createReactive } from "@supergrain/kernel";
import { createReactive, effect } from "@supergrain/kernel";
import { getActiveSub, setActiveSub } from "@supergrain/kernel/internal";
import { effect } from "alien-signals";

/**
* A resource is a reactive function with cleanup logic — one of two
Expand Down
3 changes: 2 additions & 1 deletion packages/js-krauset/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ export default defineConfig({
// and removes the need for a pnpm workspace when you copy this package.
resolve: {
alias: {
"@supergrain/kernel": resolve(__dirname, "../kernel/src/index.ts"),
"@supergrain/kernel/react": resolve(__dirname, "../kernel/src/react/index.ts"),
"@supergrain/kernel/internal": resolve(__dirname, "../kernel/src/internal.ts"),
"@supergrain/kernel": resolve(__dirname, "../kernel/src/index.ts"),
},
},

Expand Down
2 changes: 1 addition & 1 deletion packages/kernel/src/batch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { startBatch, endBatch } from "alien-signals";
import { startBatch, endBatch } from "./system";

/**
* Run a synchronous callback with all signal writes coalesced into a single
Expand Down
3 changes: 1 addition & 2 deletions packages/kernel/src/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
* `wrap()` dispatch here when the value is a Map or Set.
*/

import { getActiveSub, startBatch, endBatch, signal } from "alien-signals";

import {
$OWN_KEYS,
$RAW,
Expand All @@ -35,6 +33,7 @@ import { profileSignalRead, profileSignalSkip, profileSignalWrite } from "./prof
// accesses the imported binding at top-level evaluation time (they don't).
// ---------------------------------------------------------------------------
import { createReactiveProxy } from "./read";
import { getActiveSub, startBatch, endBatch, signal } from "./system";

function wrap<T>(value: T): T {
if (!isWrappable(value)) {
Expand Down
38 changes: 37 additions & 1 deletion packages/kernel/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { signal } from "alien-signals";
import { createObservationNode, signal, type ReactiveNode } from "./system";

// Phantom brand for compile-time store identification (no runtime property).
// Exported as a real symbol so consumers can reference `typeof $BRAND` in type positions.
Expand Down Expand Up @@ -27,6 +27,7 @@ export const $PROXY = Symbol.for("supergrain:proxy");
export const $TRACK = Symbol.for("supergrain:track");
export const $RAW = Symbol.for("supergrain:raw");
export const $VERSION = Symbol.for("supergrain:version");
export const $OBSERVE = Symbol.for("supergrain:observe");
export const $OWN_KEYS = Symbol.for("ownKeys");

// Well-known symbol properties attached to reactive proxy targets and proxies.
Expand Down Expand Up @@ -98,3 +99,38 @@ export function getNode(nodes: DataNodes, property: PropertyKey, value?: unknown
nodes[property] = newSignal;
return newSignal;
}

// Fallback liveness-node store for targets that can't carry the `$OBSERVE`
// property (frozen / non-extensible). Keyed by raw target so the node is still
// deduped (observation would silently break if each call returned a new node).
const frozenObservationNodes = new WeakMap<object, ReactiveNode>();

/**
* Retrieve the dedicated "liveness" reactive node for a reactive proxy,
* creating it lazily and stashing it on the raw target. Unlike the per-property
* signals (which fire on writes), the liveness node is never written — it exists
* purely so observation primitives (`onObservationChange`/`trackNode`/
* `isObserved`) can detect when the proxy has no remaining reactive observers.
*
* The node is stable across calls for a given target (incl. frozen targets, via
* a WeakMap fallback), so observation handlers attach to the same node a reader
* subscribes to.
*/
export function getObservationNode(value: object): ReactiveNode {
const raw = unwrap(value) as ReactiveTagged & { [$OBSERVE]?: ReactiveNode };
const existing = raw[$OBSERVE] ?? frozenObservationNodes.get(raw);
if (existing) return existing;

const node = createObservationNode();
try {
Object.defineProperty(raw, $OBSERVE, {
value: node,
enumerable: false,
configurable: true,
});
} catch {
// Frozen / non-extensible target: keep the node deduped via the WeakMap.
frozenObservationNodes.set(raw, node);
}
return node;
}
18 changes: 13 additions & 5 deletions packages/kernel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@
export { createReactive, unwrap, $BRAND, type Signal, type Branded } from "./store";
export { getNodesIfExist, $TRACK } from "./core";

// Re-export signal primitives from alien-signals for convenience.
// `startBatch`/`endBatch`/`getActiveSub`/`setActiveSub` are intentionally
// not re-exported — they mutate global counters and leak unsafely on
// exception. Use `batch()` (below) instead. Internal consumers can still
// Re-export signal primitives from the kernel's owned reactive system for
// convenience. `startBatch`/`endBatch`/`getActiveSub`/`setActiveSub` are
// intentionally not re-exported — they mutate global counters and leak unsafely
// on exception. Use `batch()` (below) instead. Internal consumers can still
// reach the raw primitives via `@supergrain/kernel/internal`.
//
// NOTE (alien-signals 3.x): `effect(fn)` now treats `fn`'s return value as a
// cleanup function — it runs before each re-run and on dispose. A callback that
// returns a non-function value (e.g. `effect(() => store.count)`) will throw
// "cleanup is not a function" on its next run. Read for subscription with a
// statement body or `void`: `effect(() => void store.count)`.
export { effect, signal, computed } from "alien-signals";
export { effect, signal, computed } from "./system";
export { batch } from "./batch";

// Reactive-observation lifecycle primitive. `onObservationChange` fires a
// callback when a reactive node loses its last observer (and, optionally, gains
// its first); `getObservationNode` returns a reactive proxy's dedicated liveness
// node to attach handlers to. Used by `@supergrain/silo` to cancel an in-flight
// fetch when no component observes a handle anymore.
export { onObservationChange, type ReactiveNode } from "./system";
export { getObservationNode } from "./core";
export {
enableProfiling,
disableProfiling,
Expand Down
9 changes: 8 additions & 1 deletion packages/kernel/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,11 @@ export { profileSignalWrite } from "./profiler";
// on exception, and setActiveSub mutates the global active-subscriber slot
// that other code assumes is restored. Public users should reach for `batch()`
// from `@supergrain/kernel` instead.
export { startBatch, endBatch, getActiveSub, setActiveSub } from "alien-signals";
export { startBatch, endBatch, getActiveSub, setActiveSub, type ReactiveNode } from "./system";

// Observation primitives. `trackNode`/`isObserved` directly read/mutate the
// reactive graph, so they live here (not the package root) alongside the other
// sharp tools. `onObservationChange` and `getObservationNode` are also re-
// exported from the package root for convenience.
export { trackNode, isObserved, onObservationChange } from "./system";
export { getObservationNode } from "./core";
3 changes: 1 addition & 2 deletions packages/kernel/src/read.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { getActiveSub, startBatch, endBatch } from "alien-signals";

import { createReactiveMap, createReactiveSet } from "./collections";
import {
$NODE,
Expand All @@ -14,6 +12,7 @@ import {
type ReactiveTagged,
} from "./core";
import { profileSignalRead, profileSignalSkip } from "./profiler";
import { getActiveSub, startBatch, endBatch } from "./system";
import { writeHandler } from "./write";

// Array methods that mutate the array internally do multiple proxy `set`
Expand Down
Loading
Loading