Always run bun make before you run bun test. All tests target the bundled output, not the source files directly, to ensure that the final bundle ready to be shipped works correctly.
Always expand branches fully, like this:
if (myStatement) {
return true; // GOOD
}Do not write code like this:
if (myStatement) return false; // BAD
if (myStatement) { return false; } // BADNever use anything other than boolean or number, if those suffice. For enums, matching, etc, always create a const value at top level, like this:
const FLAG_STALE = 1;
const FLAG_PENDING = 2;
/** @param {number} flag */
function setFlag(node, flag) { }Do not write code like this:
/** @param {string} flag - 'stale' or 'pending' */
function setFlag(node, flag) { }Always avoid heap allocations when possible. Prefer code duplication over heap allocs. Never allocate strings, arrays, destructured return arguments unless absolutely necessary.
anod is extremely sensitive to V8 optimization. Code that looks equivalent often isn't — prototype methods beat free functions in polymorphic dispatch, Array.prototype.pop() beats arr.length-- for swap-remove, and multiple call sites can prevent inlining that a single call site with an intermediate variable enables. Always prefer small flat structs with inline fields for the common case and arrays only on overflow.
Always write meaningful comments about how the code works. Do not insert meaningless section comments. Prefer JSDoc style comments over regular // comments.
If you can, add correct JSDoc type definitions. Because we "fake" a lot of typescript features, this project is built on javascript and uses jsdoc for type safety, instead of Typescript. That way, we can fully create a virtual API through typescript that fakes the node types, flags etc.
anod is a fine-grained reactive signal library for JavaScript. It belongs to the same family as S.js, Solid signals, Preact signals, and Alien signals, but takes its own approach to scheduling, dependency tracking, and memory layout.
The monorepo contains two packages:
anod-core— the core reactive engineanod-list— reactive array methods built on top of the signal primitives
There are four sync node types plus two async variants, distinguished by bit-flag type tags. Type flags encode capabilities: sender (can broadcast changes), receiver (can subscribe to changes), owner (can own child nodes for hierarchical disposal).
Signal — a writable reactive cell. Holds a value, notifies subscribers on set() when the value changes (!==). Has no dependencies, only subscribers. Created via signal(value). Relay — a signal variant that always notifies on set(), bypassing equality check. Created via relay(value). Useful for mutable objects.
Compute — a derived reactive value. Runs a function to produce its value, automatically tracking which senders it reads. Purely pull-based: notifications mark it stale/pending but it does not re-execute until something reads it. Created via compute(fn) (unbound, dynamic deps) or compute(dep, fn) (bound, single dep).
Effect — a side-effect sink. Tracks dependencies like Compute but produces no value. Re-runs eagerly via the transaction loop when any dependency changes. Can own child nodes and register cleanup functions. Created via effect(fn) or effect(dep, fn).
Task — async Compute. Same shape as Compute with FLAG_ASYNC set. Runs a function that returns a Promise. While pending it holds FLAG_LOADING; on resolution it settles its value and pushes notification to its subscribers. Created via task(fn) or task(dep, fn).
Spawn — async Effect. Async counterpart of Effect — side-effectful, owns cleanups, runs async work. Created via spawn(fn) or spawn(dep, fn).
Root / scope — ownership boundaries. root(fn) creates a top-level ownership scope; scope() creates a nested one. Disposing an owner recursively disposes owned nodes and runs their cleanups.
anod separates notification from evaluation:
- Notification is push: when a Signal writes, it walks its subscriber list and marks each receiver
FLAG_STALE(direct dep) orFLAG_PENDING(transitive). Stale/pending propagation stops at nodes already marked for the current transaction time. - Compute evaluation is pull: Computes never re-run as part of notification. They re-run on the next read that finds them stale. This avoids work for values nobody reads.
- Effects are pushed to queues: flat effects go to
RECEIVERS, scoped effects go toSCOPES[level](level = owner depth). The transaction loop drains these in order after signals settle. - Tasks push on resolve: when a task's promise settles, it pushes its update into the
TASKSqueue of the current transaction. The loop processes tasks that resolved mid-flight — they don't need to be pulled by effects, since they notify their subscribers when they have an actual value to deliver. If no active transaction exists, settling a task starts a new one.
start(fn) runs fn() and then drains all queues. Nested start() calls are no-ops (inline execution). The loop order each cycle:
- Disposals — nodes queued for disposal
- Signal updates —
SENDERS/PAYLOADS(batched senders) - Tasks —
TASKSqueue (async results that resolved during this cycle) - Scoped effects by level —
SCOPES[0],[1],[2], ... parent-before-child - Flat effects —
RECEIVERS
After each queue drains, CLOCK._time advances. Any node that runs writes that schedule new work brings the loop back to step 1. A runaway guard caps iterations at 100,000 and throws "Runaway cycle" if exceeded.
Tracking is dynamic at runtime. When a receiver's function calls read(sender), a bidirectional link is created.
Links use a dual-pointer + overflow array layout to avoid allocations in the common case:
- Sender side:
_sub1(first subscriber),_subs[](packed array of[receiver, depslot]pairs for additional subscribers). - Receiver side:
_dep1(first dep inline),_deps[](packed array of[sender, subslot]pairs for 2+ deps).
Reconciliation on re-run uses pruneDeps() with version-tagging to handle three cases in one pass:
- Recycled deps — re-accessed in the same order; no action needed
- Stale deps — not re-accessed; unsubscribed and swap-removed
- Reordered deps — accessed out of order; detected by version tag, moved rather than resubscribed
Unsubscription uses swap-with-last (Array.prototype.pop()) for O(1) removal. Arrays are always kept packed, no null gaps.
Bound nodes (FLAG_BOUND) are created via compute(dep, fn) / effect(dep, fn) / task(dep, fn) / spawn(dep, fn). They have a fixed single dep passed at construction. The callback receives the dep's value directly as the first argument — no read() call needed, no setup, no reconciliation.
This is critical for performance: V8 must see both bound and unbound paths exercised from program start. Without real bound-dep callsites, V8 dead-code-eliminates the bound fast path and the later transition causes deopt. Measured: adding a stable-fast-path branch without exercising it caused 1.08μs → 1.28μs regression with IPC dropping from 6.19 → 4.52. The bound path is 30-50% faster and uses 60-90% less memory than unbound for the single-dep case.
Unbound nodes are created via compute(fn) / effect(fn) / etc. The callback receives the node itself as first argument, exposing read() for dynamic dep tracking.
Callback signatures:
- Bound sync:
(val, c, prev, args) => ...—valfirst socompute(age, a => a > 18)is natural;csecond when helpers are needed. - Unbound sync:
(c, prev, args) => ...—cis the node itself. - Bound async:
(val, c, prev, args) => ...— same as sync.cis always needed for async because of cleanup / future suspend. - Unbound async:
(c, prev, args) => ...— same as sync unbound.
- Default: dynamic dependency tracking with full reconciliation
Opt.STABLE: no dependency tracking at read time; the bound callback just receives the dep's value. Fastest but can't change depsOpt.DEFER: don't auto-start on creation; remain STALE until explicitly read
Tasks and spawns are async nodes. The body returns a Promise (or async iterable). The library:
- Sets
FLAG_LOADINGon the node while the promise is pending. - Stores the old value during loading;
.val()returns the last-known value (stale-while-revalidate). - Uses
WeakRefto hold the node reference from the resolution handler, allowing GC if the node is disposed before the promise settles. - On invalidate mid-flight, pushes to the
TASKSqueue of the current (or new) transaction, does not notify subscribers, it only notifies on settle. - Exposes
task.loading()andtask.error()as separate observable flags.
Stale activation semantics: if a task re-runs before its previous promise resolves, the old activation is logically abandoned. When the old promise resolves, the library checks the node's current activation id — if it doesn't match, the resolution is discarded. This prevents stale values from overwriting newer results.
batch(fn) defers propagation until fn completes. Signal sets within a batch coalesce via SIGNAL_QUEUE / SIGNAL_OPS. Nested batch() calls inline. batch is distinct from start — batch is pure coalescing within an existing transaction, while start is the transaction loop itself.
Effects can have owned child nodes.
Owner nodes track _owned children. Disposal is recursive. Cleanup functions are stored compactly: single function for count=1, array with slot recycling for count>1. Scoped effects track _level (owner depth) and execute via SCOPE_QUEUE[level], parent-before-child, without a full topological sort.
All errors in anod are { error, type } POJOs with three type constants:
REFUSE(1) — expected error fromc.refuse(val). Non-throwing, the compute returns the error value.PANIC(2) — expected error fromc.panic(val). Throws, butFLAG_PANICdistinguishes it from unexpected errors.FATAL(3) — unexpected error. Any uncaught throw withoutFLAG_PANICis wrapped as{ error: thrownValue, type: FATAL }.
Errors in compute bodies are caught and stored with FLAG_ERROR set. Reading an errored compute via .val() rethrows the full { error, type } POJO. Errors in effects dispose the effect. recover(fn) on a compute/effect intercepts errors: the handler receives the POJO and can branch on type to distinguish user errors from crashes. Return true to swallow, false to propagate.
anod-list extends Signal.prototype and Compute.prototype with reactive array methods. Two categories:
Read methods (return a bound Compute that re-runs when the source array changes):
at, concat, entries, every, filter, find, findIndex, findLast, findLastIndex, flat, flatMap, includes, indexOf, join, keys, map, reduce, reduceRight, slice, some, values
Mutation methods (only on Signal, mutate in place and notify):
copyWithin, fill, pop, push, reverse, shift, sort, splice, unshift
Read methods support reactive parameters — args can be plain values, Signals, or functions, resolved at execution time via getVal(). Example: .slice(signal(0), signal(5)) creates a reactive slice that updates when either bound changes.
forEach is special — creates an Effect rather than a Compute, since it's inherently side-effectful.
The list(value) factory creates a Signal with FLAG_LIST set, the entry point for the list API.
-
Pure pull for computes, push for effects and tasks. Computes never run unread; effects run when queued; tasks push their settled results. This minimizes wasted work without sacrificing responsiveness.
-
Dual-pointer dep storage. First dep/sub inline, overflow into packed arrays. Avoids allocation in the ~80% common case.
-
Bound-dep fast path.
compute(dep, fn)skips all setup/reconciliation. 30-50% faster than unbound for single-dep cases. Must be exercised from startup or V8 dead-codes it. -
Version-tag reconciliation. One-pass dep pruning with in-place compaction. No diff, no splice, no nulls.
-
Topological effect levels. Scoped effects execute parent-before-child via level-indexed queues.
-
Bit-packed state. All node state in a single 32-bit flag field. Fast branching with bitwise ops.
-
Zero-allocation hot paths. No strings, no closures on reads/writes. Queues pre-allocated. Arrays kept packed.
-
WeakRef async. Async resolution handlers hold nodes weakly, so disposed nodes GC even if their promise is still pending.
-
JSDoc + TypeScript declarations. Source is plain JS with JSDoc for Closure Compiler; public API is a separate
.d.ts.