The reactor engine — decided
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 NaN → NaN 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:
- subscribed diamond under
batch— one recompute, one notify, andseenproves no half-updated pair. This is the property the always-batch rule exists for. - synchronous notification — a subscriber has run before
setreturns, and before an enclosingbatchreturns. The publish-ordering seamkill.featureandstreamOrdering.test.tsrest on. A future Atom bump that deferred notification to a microtask goes RED here instead of silently reordering the wire. Object.isequality / NaN — divergence #4, pinned rather than remembered.
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:
@vue/reactivity(standalone): exports nobatch/flush— Vue’s batching lives in@vue/runtime-core’s scheduler, soreactor.tswould hand-roll exactly the frame the engine was supposed to own.alien-signals: a throwing computed’s second read silently returnsundefined(probed) — thecaught-error-must-not-collapse-to-emptydefect class, verbatim.@reactively/core: dead (last publish 2023) and no effect disposal API at all.- TC39
signal-polyfill: the README’s first line forbids production use; Stage 1 with no advancement; no effect, no batch. - solid-js 1.x deep-imported into Node: the
nodecondition routes todist/server.jswhosecreateEffectis a no-op; thedist/solid.jsworkaround risks two disjoint silent reactive graphs in one process — the worst failure mode under fail-fast. @solidjs/signalspassed its standalone Node probe and was the named swap target under the old decision. It is no longer named — not because it failed, but because “one engine on both sides” stopped being the deciding axis the moment the daemon’s whole vocabulary became Effect. kolu’s frontend stays on Solid 1.9 regardless; the ecosystem still peer-excludes 2.x.
| 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 |