← the Atlas

The reactor engine — decided

Reference·budding·accepted·

The ratified engine decision for the reactive bridge: Effect's Atom (effect/unstable/reactivity) behind reactor.ts, adopted when the whole repo moved to Effect. The swap off @preact/signals-core was 13 call sites and zero wrapper-test edits — the two-way door proving itself. Records the four measured Atom-vs-preact divergences, each closed once inside the wrapper, and the probed six-engine comparison that stands as the reason none of the alternatives is a fallback.

The decision. The reactive bridge’s engine is Effect’s Atom/AtomRegistry (effect/unstable/reactivity), behind reactor.ts. It replaced @preact/signals-core, which held the slot from the bridge’s first slice until the Effect campaign. There is no named swap target any more: the point of a wrapper is that the next engine is whichever one passes the laws, and the last swap proved the door opens.

The reason is not that Atom is a better graph library than preact’s. It is that the daemon’s concurrency, lifetime and error vocabulary is now Effect end to end — fibers, scopes, Ref, Queue, Stream — and a second, unrelated graph library sitting inside that process is one dependency, one set of semantics and one failure mode more than the codebase needs. The engine seam was always meant to make that call cheap. It was: 13 call sites, zero edits to the wrapper’s own tests.

What the seam is, and what it hides

reactor.ts defines exactly four primitives and nothing else — signal (a mutable root), derive (lazy, glitch-free), effect (subscriber + disposer), batch (one frame) — over ONE module-level AtomRegistry. The registry is deliberately not behind a Layer: putting it there would drag every consumer of source / scan / computed into an Effect context, and the bridge’s face is, and must remain, a synchronous non-Effect surface that a plain callback can write to.This is the same line the run-edge ledger draws: the reactor’s poll dep stays () => Promise<T> and everyMs stays a setInterval. The ENGINE is Effect; the FACE is not. Collapsing the two would put an Effect.runPromise inside reactor.ts.

biome.jsonc bans effect/unstable/reactivity repo-wide with two overrides — reactor.ts and the laws test. That pin is what makes “one exit from the graph” structural rather than a review convention, and it is the reason this swap cost thirteen call sites instead of an audit: the pin outlived the engine it was first written for.

The four divergences, each closed once

Atom is not preact with different spelling. Four behaviours differ, all found by running the laws against the raw engine, and each is absorbed by the wrapper so no call site ever meets it.

# Atom’s behaviour Where it is closed
1 No implicit batch. A write notifies immediately; preact’s batch() was explicit but the bridge wants every write batched. The batch is baked into signal’s SETTER. batch() and a source emit merely nest (depth-counted). A missed batch is now unspellable — no site can forget.
2 A throwing derivation escapes on the writer’s stack. A derivation’s node value is a Result<T, unknown>, so the atom read is total; the throw is re-raised at .value / .peek(). Equality is Atom.withEquality over the PAYLOAD, or a fresh Result object per recompute would republish every frame and lose the equality-cascade stop.
3 Auto-removal loses state — an unobserved node is dropped and its next read starts over. signal() bakes in Atom.keepAlive. Pure derivations still auto-dispose, which is what you want.
4 Object.is equality, so a NaNNaN write does not propagate (preact’s !== republished). Adopted deliberately: fewer spurious wire frames, and no test anywhere pinned the old behaviour. Stated in the laws rather than left to be discovered.

The dual edge — the one nobody predicted

padi/src/activity/finishQuiet.ts holds a derivation that WRITES a level it just READ. Atom invalidates a level’s dependents by clearing that level’s children set — which, for the derivation currently mid-recompute, drops an edge it had already established and cannot re-establish. The next bump then reached nobody, and padi’s urgency froze one frame behind. A real red, caught by finishQuiet.dualEdge.test.ts.

The fix lives in signal’s setter: re-assert the edge, and only for a level the running derivation genuinely read (tracked in currentDeps) — a derivation that merely writes a level must not acquire a dependency on it, which would be a loop. O(1), on the pathological path only. preact gave this away for free, because its target list is not cleared mid-computation; the code now states the behaviour and names the incident it protects.

The cost, stated: an unstable namespace

effect/unstable/reactivity is exactly what its path says — an unstable namespace, with ~effect-atom/* heritage still visible in its own type ids. The engine is a moving target across beta bumps in a way @preact/[email protected] was not. That is a real cost and it is not talked away; it is paid for by the laws test, which was rewritten against the raw engine and grew from four laws to seven:

The probed comparison — why nothing here is a fallback

The table below is the original six-engine evaluation (source reads plus live Node probes, Node 24.14.1, 2026-07-10), kept because it is still the reason the alternatives are not candidates. Its eliminations have not aged:

Atom (effect/unstable/reactivity) @preact/signals-core @vue/reactivity alien-signals @reactively/core signal-polyfill (TC39) solid-js 1.x deep-import @solidjs/signals 2.0β
Node viability ✅ already in the closure — zero new deps ✅ zero deps, no globals ✅ (+@vue/shared) ✅ zero deps ⚠️ dual-graph trap, unsupported ✅ core pkg; solid-js itself still server-stubbed
Glitch-free + batch ✅ probed; no implicit batch — baked into the setter ✅ explicit nested batch(), promptly reads no batch export — hand-roll scheduler startBatch/endBatch ✅ manual stabilize() graph yes; no effect/batch ✅ (browser build) ✅ implicit microtask batch, reads stale until flush()
Equality-cascade stop Atom.withEquality; Object.is default (NaN divergence) ✅ probed; no equals opt ✅ probed; no equals opt ✅; prev param workaround ✅ custom equals ✅ spec equals ✅ solid equals opt ✅ solid equals opt
Effect disposal / long-lived ownership ✅ disposer; auto-removal needs keepAlive on roots ✅ disposer + cleanup + watched/unwatched hooks effectScope ✅ disposer + scope no dispose API ⚠️ DIY on Watcher createRoot createRoot
Error propagation ✅ total read via Result, re-raised at the wrapper ✅ cached + rethrown every read ✅ throws at reader/setter swallows: 2nd read returns undefined ❌ none ✅ ERRORED rethrow solid semantics solid semantics
Maintenance ⚠️ unstable namespace, moves with the beta ✅ Preact team, stable 1.x ✅ Vue team; 3.6 swaps internals ✅ active; Vue 3.6 basis ❌ dead (2023) ❌ “do not use in production” ✅ solid team (usage unsupported) ⚠️ beta, active daily
reactor.ts adaptation done: 4 divergences + the dual edge, 13 call sites trivial medium (write the scheduler) small + must add error discipline large large none, but fragile none API-wise; add flush()-at-publish