dalien-signals

dalien-signals is a data-oriented fork of alien-signals, a fast signals reactivity library. Its dependency graph is stored in a single Int32Array memory arena, like a contiguous array of structs in C, rather than as JavaScript objects.

All UI on this page is vanilla JS with dalien-signals.

Stress test: one signal per pixel

Every pixel is a signal or computed, plus an effect to paint it. The canvas alternates 64-row bands of two shapes of dependency that stress opposite ends of a signals implementation.

Frame compute times each frame's write batch — mark the cones of dependents, recompute them, run the queued render effects. An adaptive controller sizes the batch to recompute ~10% of the graph each frame, so the load scales with the graph.

Every cell rounds its output to a coarse step, so fading ripples recompute to the value they already held and propagation stops there — the equality cutoff at work across the whole field.

The three primitives

dalien-signals keeps calculations and callbacks in sync with changing values. It provides three main primitives:

Dependencies are automatic. While a computed or effect runs, the library records every signal and computed it reads — reading inside a computed or effect is the subscription.

import { computed, effect, signal } from "dalien-signals";

const count = signal(0);
count(2); // set state

const doubled = computed(() => count() * 2);
const isEven  = computed(() => count() % 2 === 0);
doubled(); // 4, cached until count changes

const stop = effect(() => {
  const original = document.title;
  document.title = `doubled = ${doubled()}`;
  return () => {
    document.title = original;
  };
});
count(100); // the effect reruns: "doubled = 200"
stop(); // stop the effect; cleanup restores the title

When count changes, the effect is queued and pulls doubled up to date; isEven recomputes only the next time it is read. The widget's effect runs cell counts an effect that reads both computeds: it is queued once per change and re-runs only when one of them produces a new value. Stepping from 1 to 3: doubled changed, but even? was no before and after — equality cutoff, the same rule that stops the field's ripples.

How a write becomes an update

Recalculating every downstream node on each write would waste work. Recalculating dependents immediately could also run an effect more than once, or before all of its inputs are current. dalien-signals instead uses a push-pull algorithm: a write does not recompute anything by itself; it records what may have changed, cheaply, and the work happens later, on demand.

  1. Push staleness. Writing a signal marks it Dirty and walks its subscriber lists, marking each downstream computed and effect Pending — "something under you may have changed." No user callback runs during this graph walk; it's a few integer writes per node.
  2. Queue effects. Any effect the walk reaches goes into a queue, once, no matter how many paths led to it.
  3. Pull and verify. Before a computed returns its value, it checks its dependencies and recalculates if necessary. A queued effect reruns and pulls each computed it reads up to date. A Pending chain resolves top-down; if a node's inputs all came out equal, its value has not changed, and the update stops along that path.
  4. Stamp the verdict. Every committed write increments one global version counter. When a computed finishes verifying, it stores that version; a later read compares the two numbers, and if they match, the value is provably current — the read returns in a couple of loads without walking anything.

Below, two signals feed a subtotal, a total, and one effect; each button narrates the operation it performs. A batch coalesces two writes into one effect run; an unchanged write produces no work; disposing the effect makes the computeds drop their own edges — an unwatched computed keeps no bookkeeping.

Memory layout: eight integers per node

Signal libraries typically allocate an object per node, wired with references the garbage collector traces on every collection. dalien-signals stores the whole graph in one Int32Array — an arena of 32-byte records, eight int32 slots each. A node is the index where its record starts:

0 · Flags
kind tag + state bits: Mutable, Watching, Dirty, Pending, …
1 · Deps
head of the dependency list — points at a link record
2 · DepsTail
the re-track cursor into that list
3 · Subs
head of the subscriber list — also a link record
4 · SubsTail
tail of the subscriber list
5 · Gen
generation counter, so stale ids of freed records miss
6+7 · Version
one float64 (via a shared-buffer view): "verified as of global version N"

Edges are records in the same array: a link record names a dependency, a subscriber, and the four neighbors threading it into both lists. Walking a list means following integers from slot to slot — nothing for the collector to trace, and adjacent records share cache lines. A free list chains unused records together, so disposed nodes and edges are recycled in place; values, getters, and callbacks live in ordinary side arrays indexed by the same id. The memory tables on this page are that array decoded row by row; amber cells changed on the last action.

The default arena reserves 64 MB of address space — 2,097,152 records of 32 bytes. Large zero-filled buffers are demand-paged: allocation reserves virtual address space, but resident physical memory grows only as pages are touched. growCapacity() raises the reservation for bigger graphs — the size bar above calls it before building each field — and the arena also grows itself when it fills.

Example: Spreadsheet

Each cell is a signal (the typed text) plus a computed (its value). Formulas start with = and are plain JavaScript; GET('A:1') reads another cell. The formula runs inside the cell's computed, so every GET records a real dependency edge — edit a formula and the memory view shows link records appearing and aging out.

Benchmarks, in this tab

The milomg js-reactivity-benchmark suites — sbench, kairo, cellx, dynamic — run against four of the selector's libraries — dalien, alien, preact, and reactively — using the benchmark's own adapters. Each (suite, library) cell runs in a fresh Worker, so no library inherits JIT state, arena contents, or main-thread contention. One round each — expect a few minutes; published numbers come from CI (interleaved rounds, isolated processes, medians).