Deterministic signals + reactive collections with patch bundles.
This is a small signals kernel designed for transparent semantics and deterministic behavior:
- Deterministic dependency tracking for derived signals via capability-passing.
- Hot/cold derived semantics (no hidden subscriptions when cold).
- Batching to coalesce notifications.
- Reactive collections (
array,object,map,set) implemented without proxies or diffing. - Immutable patch bundles (
patches+inversePatches) designed to plug intotracer-history.
This package is ESM-only ("type": "module").
If you’re working inside this repo directly:
import { signal, batch, overridable } from './src/index.js'If you’re using it as an installed package (i.e. tracer is on your node_modules resolution path), you typically import:
import { signal, batch, overridable } from 'tracer'Derived signals are created by passing a function that receives a single capability: track.
const count = signal(1)
const doubled = signal(track => {
const c = track(count)
return c * 2
})This is intentionally different from “ambient” tracking models:
- Dependencies are only captured when you call
track(dep). - Nothing “magically” subscribes because you happened to read a value in a reactive context.
- You can build derived values with dynamic dependencies, and the library will subscribe/unsubscribe correctly.
const useA = signal(true)
const a = signal(1)
const b = signal(10)
const chosen = signal(track => track(useA) ? track(a) : track(b))Derived signals have two modes:
-
Cold (no subscribers)
- Holds no upstream subscriptions.
- Recomputes on
getValue().
-
Hot (has subscribers)
- Subscribes to upstream dependencies.
- Recomputes and notifies when dependencies change.
- Tears down upstream subscriptions automatically on last unsubscribe.
This keeps “read-only computations” cheap when no one is listening, and avoids hidden retained subscriptions.
batch(fn) coalesces many updates into a single downstream notification per signal.
const s = signal(0)
batch(() => {
s.setValue(1)
s.setValue(2)
s.setValue(3)
})Subscribers see one update with:
previousValue: value at the start of the batchnextValue: final value at the end of the batch
Batch flushing occurs even if an error is thrown inside the batch (the error is rethrown after flushing).
Creates a mutable signal:
getValue(): anysetValue(nextOrUpdater): boolean(returnsfalseon no-op)subscribe(cb): () => void
subscribe(cb) calls cb immediately with an init change.
Creates a computed signal:
getValue(): anysubscribe(cb): () => void
Derived signals do not expose setValue.
Batches notifications across any signals updated during fn.
Wraps a signal so it can temporarily stop following its base value.
const base = signal(1)
const o = overridable(base)
o.getValue() // 1
o.setValue(10) // overrides base
o.getValue() // 10
o.clear() // returns to following baseSubscribers receive change objects:
{
kind: 'init' | 'update',
nextValue: any,
previousValue: any,
meta?: any
}Notable properties:
initchanges havepreviousValue === undefined.updatechanges contain bothpreviousValueandnextValue.metais used heavily by reactive collections to publish patch bundles.
Collections are implemented with explicit mutation APIs (no proxies, no diffing). Each collection supports:
getValue()returning a frozen snapshot / viewsetValue(nextOrUpdater)to replace the entire valuemutate(fn)to perform granular edits and emit patch bundles
On collection mutations, mutate(fn) returns either:
null(no-op mutation)- a frozen bundle:
{
patches: Patch[],
inversePatches: Patch[]
}The same bundle is also published to subscribers as change.meta.
Design points:
- Deep immutability: bundles, patch arrays, and patch objects are frozen.
- Undo-friendly:
inversePatchesare recorded in the correct order to undo the mutation. - Batch-friendly: when multiple mutations happen in a
batch(), patch bundles compose deterministically.
const items = signal.array([1, 2, 3])
items.index.length.subscribe(change => {
if (change.kind === 'init') return
console.log('length changed:', change.nextValue)
})
const bundle = items.mutate(m => {
m.push(4)
m.set(0, 10)
})
// bundle.patches / bundle.inversePatches describe the mutationArray mutators include push, pop, shift, unshift, splice, set.
Reactive object with key indices:
index.keys: signal ofstring[]index.size: signal ofnumber
Mutators include set(key, value), delete(key), assign(partial).
Reactive Map with:
index.keys: signal of ordered keysindex.size: signal of sizekey(k): a stable derived signal describing a single key:{ present: boolean, value: any }
const m = signal.map([[1, 'a']])
const k1 = m.key(1)
k1.subscribe(change => {
if (change.kind === 'init') return
console.log(change.nextValue) // { present, value }
})Reactive Set with:
index.values: signal of ordered valuesindex.size: signal of sizevalue(v): a stable derived signal describing membership:{ present: boolean }
Collection mutations produce exactly what patch-based history wants: a { patches, inversePatches } bundle.
tracer-history is intentionally generic: you provide applyPatches(patches) that knows how to interpret your patch format.
import createHistory from 'tracer-history'
import { signal } from 'tracer'
const state = signal.object({ name: 'Ada' })
let applyingHistory = false
const history = createHistory({
limit: 100,
applyPatches: patches => {
applyingHistory = true
try {
// Minimal example: apply patches by converting to a full replacement.
// (You can also implement a patch interpreter; the important part is
// that you DO NOT call history.record() while applying.)
const next = { ...state.getValue() }
for (const p of patches) {
if (p.op === 'set') next[p.key] = p.value
else if (p.op === 'delete') delete next[p.key]
else if (p.op === 'replace') {
Object.assign(next, p.value)
}
}
state.setValue(next)
} finally {
applyingHistory = false
}
}
})
function mutateWithHistory (fn) {
const bundle = state.mutate(fn)
if (!bundle) return
if (applyingHistory) return
history.record(bundle)
}
mutateWithHistory(m => m.set('name', 'Grace'))
history.undo()
history.redo()For plain stored signals, you can emit your own patches:
const count = signal(0)
const history = createHistory({
applyPatches: patches => {
for (const p of patches) {
if (p.op !== 'set') throw new Error('Unexpected patch op')
if (p.name === 'count') count.setValue(p.value)
}
}
})
function setWithHistory (name, sig, value) {
const previous = sig.getValue()
history.perform({
patches: [{ op: 'set', name, value }],
inversePatches: [{ op: 'set', name, value: previous }]
})
}
setWithHistory('count', count, 1)
history.undo()- No ambient tracking: dependencies are explicit and deterministic.
- No hidden subscriptions: cold derived values do not retain upstream subscriptions.
- No proxies / diffing: collections mutate through explicit APIs.
- Patch-first design: mutation bundles are immutable, undo-friendly, and batch-composable.
- History-ready: patches + inverse patches are first-class, designed to integrate with
tracer-history.