packages/state/SPEC.md
This document states the rules that @tldraw/state implements. It is written to drive testing: each rule has a stable ID (e.g. EP2, T5), each rule is independently observable through the public API (or the documented internal API where noted), and the unit tests should be an expression of these rules. When a test and this document disagree, one of them is wrong — figure out which and fix it.
Sections marked internal describe supporting machinery (ArraySet, HistoryBuffer, capture functions) that has its own contract worth testing directly, even though users never touch it.
EffectScheduler, usually via react() or reactor().RESET_VALUE is a sentinel meaning "no diff available; consumers must start over from the current value."lastChangedEpoch: the global epoch at the moment its value last actually changed. For a computed, recomputing to an equal value does not update lastChangedEpoch.a and a new value b is: a === b, else Object.is(a, b), else a.equals(b) if a has a callable equals method.equals method is consulted. A b.equals method alone has no effect.isEqual option is provided to atom() or computed(), it replaces default equality for change detection on that signal.get() keeps returning the old reference.atom(name, initialValue, options?) creates an atom whose get() returns initialValue.atom.set(value) makes subsequent get() calls return value, unless value is equal (EQ) to the current value, in which case the call is a complete no-op.atom.set returns the atom's value after the call (the new value, or the unchanged current value).atom.update(fn) is exactly atom.set(fn(currentValue)). It does not accept a diff.atom.get() captures the atom as a parent of the currently-executing computed or effect (see CAP). atom.__unsafe__getWithoutCapture() returns the same value without capturing.lastChangedEpoch.get() (or getDiffSince()) is called is captured as a parent of that computed/effect.unsafe__withoutCapture(fn) runs fn with capture disabled and restores the previous capture context afterwards, even if fn throws. Signals dereferenced inside it do not become parents, and changes to them alone do not re-run the enclosing computed/effect.children set is empty whenever nothing is actively listening downstream.startCapturingParents/stopCapturingParents/maybeCaptureParent reuse the child's existing parents/parentEpochs arrays (no reallocation), shrinking them in place when fewer parents are captured, and recording capture order as dereferenced.get().get() calls re-run the compute function only if at least one parent's lastChangedEpoch differs from the epoch recorded when that parent was last dereferenced. Epoch advances unrelated to the computed's parents never cause recomputation.(previousValue, lastComputedEpoch). On the first computation previousValue is the UNINITIALIZED symbol (test with isUninitialized); afterwards it is the previous value. lastComputedEpoch is the epoch at which the computed last finished computing.lastChangedEpoch; downstream children observe no change. The signal's isEqual is never invoked for the first computation.computed.isActivelyListening is true exactly when the computed has at least one attached child (effect or actively-listening computed downstream).@computed decorator on a class method makes that method behave as Computed.get() for a per-instance computed signal: cached, reactive, with options (isEqual, historyLength, etc.) honored. Both legacy and TC39 decorator protocols are supported.getComputedInstance(obj, propertyName) returns the underlying Computed instance for a decorated method, creating it on demand if the method has not been called yet.@computed on a getter (legacy decorators only) still works but logs a one-time deprecation warning per process.get() calls rethrow the same value without re-running the compute function, until a parent changes.UNINITIALIZED as previousValue, and the error state clears.getDiffSince spanning the error returns RESET_VALUE.__unsafe__getWithoutCapture(true) suppresses the rethrow (used internally so haveParentsChanged can be called on a throwing graph without exploding), and the computed still participates in capture.historyLength was passed at creation. computeDiff without historyLength does nothing.diff argument to set(value, diff), else computeDiff(prev, next, lastChangedEpoch, currentEpoch), else RESET_VALUE.withDiff(value, diff) return value, else computeDiff(...), else RESET_VALUE. No entry is recorded for the first computation.RESET_VALUE as a diff clears the entire history buffer. In particular, failing to supply a diff on a signal that has history but no computeDiff wipes its history.getDiffSince(epoch) returns:
EMPTY_ARRAY if epoch >= lastChangedEpoch (nothing changed since);RESET_VALUE if the signal has no history buffer;epoch → now, if the buffer reaches back that far;RESET_VALUE (history evicted: more than historyLength changes since epoch, or the buffer was cleared, or epoch predates the signal's first computation).getDiffSince captures the signal as a parent, like get(). On a computed it first brings the value up to date.getDiffSince from before the transaction returns RESET_VALUE). A computed's history is not cleared by an abort; it records the round-trip (the change and the change back) as ordinary entries.withDiff(value, diff) is only meaningful as a computed's return value; it is unwrapped so get() returns value, and diff feeds the history buffer per H3.react(name, fn) runs fn immediately and unconditionally, then re-runs it whenever any signal captured during its previous run changes. It returns a stop function; after stopping, changes no longer re-run fn.reactor(name, fn) does not run fn until .start(). .start() runs the effect if it has never run or if any parent changed while stopped; otherwise it does not run. .start({ force: true }) always runs it. .stop() detaches. Start/stop may be repeated.signal.getDiffSince(lastReactedEpoch) inside effects. The epoch passed is the one captured before the run, so an effect that updates atoms during its own run remains eligible to be scheduled again.scheduleEffect option, scheduling and execution are decoupled: when the effect would run, scheduleEffect(execute) is called instead and nothing executes until the callback invokes execute. The initial run of react() is also routed through scheduleEffect. scheduleCount counts scheduling events, whether or not the effect later executes.execute callback runs, executing the callback is a no-op.EffectScheduler.attach() does not by itself run the effect (execute() must be called the first time; afterwards changes schedule it per E3/E4). detach()/attach() cycles retain the captured parents: re-attaching does not re-run the effect unless a parent changed while detached (matching E2 since reactor is a thin wrapper).maybeScheduleEffect on a detached scheduler is a no-op. On an attached scheduler whose parents are unchanged it is a no-op (but marks the scheduler as up to date with the current epoch).set returns.Reaction update depth limit exceeded. An effect that unconditionally writes to one of its own parents hits this limit.transact/transaction call) during the reaction phase gets the same deferral: the inner transaction's effects are folded into the current reaction phase rather than flushed reentrantly.transaction(fn) starts a new transaction, always. transact(fn) joins the current transaction if one exists, else behaves like transaction(fn). Both return fn's return value — including when the transaction rolls back.sets are visible immediately to subsequent reads, and computeds recompute on demand against in-transaction values.fn receives a rollback callback. Calling it (and then returning normally) aborts the transaction: every atom changed during the transaction is restored to the value it had when the transaction began.fn throws, the transaction aborts as in T4 and the exception propagates.transaction calls roll back independently: an inner rollback restores to the inner transaction's start. A committed inner transaction is still undone by an outer rollback (the inner transaction's initial values fold into the parent on commit).transact joins rather than nests, a throw inside an inner transact does not restore anything by itself; if it propagates out of the outermost transaction, T5 applies there.Transaction boundaries overlap.deferAsyncEffects(fn) runs the async fn in a transaction context: atom changes are visible immediately to reads (T2 applies), but effects are deferred until the async transaction completes.transaction/transact calls inside the async body are fine and nest normally.fn rejects or throws, all changes made during the async transaction are rolled back and the error propagates.deferAsyncEffects while another async transaction is in flight joins it: changes from both are batched together, and effects run only after the last participating process finishes. Async transaction state leaks across await boundaries between concurrent processes (no AsyncContext); the grouping of effects is the guarantee, not isolation.deferAsyncEffects call kicked off during the reaction phase waits for the reaction phase to finish before starting.fn's return value.name for debugging. Names need not be unique and have no behavioral effect.whyAmIRunning() throws if called outside a computed or effect execution.console.log) a tree of which ancestor signals changed, by name; if it was executed without any ancestor change, it logs that it was executed manually.isAtom(v) is true exactly for values created by atom(); isComputed(v) exactly for computed signals (including instances behind decorated methods); isSignal(v) is isAtom(v) || isComputed(v). All three return false for null, undefined, plain objects, functions, and each other's instances.globalThis singletons. If two copies of the library are loaded in one realm, they share one reactive universe and the type guards in G1 work across copies.localStorageAtom(name, initialValue, options?) returns [atom, cleanup]. The atom's starting value is the JSON-parsed localStorage entry for name when one exists, else initialValue.initialValue is used. An empty-string entry is treated as absent.storage event for the same key updates the atom with the parsed new value; a storage event with newValue: null resets the atom to initialValue; an unparseable newValue and events for other keys are ignored.cleanup() stops localStorage writes and storage-event handling. The atom itself keeps working as a plain atom. Atom options (equality, history) pass through per A/H rules.ArraySet is the dependency-set data structure. Contract: it behaves as a set under add/remove/clear/has/size/isEmpty/visit/iteration.
add returns true and inserts when the element is absent; returns false and does nothing when present. remove returns true and deletes when present; returns false otherwise.visit and [Symbol.iterator] yield each element exactly once; has, size, and isEmpty are consistent with the elements yielded.ARRAY_SIZE_THRESHOLD (8) elements in an array, promoting to a Set on overflow. Behavior is identical in both modes and across the promotion boundary, including after interleaved adds, removes, and clears.HistoryBuffer is the circular diff store behind H1–H5.
pushEntry(fromEpoch, toEpoch, diff) stores a diff covering the epoch range; pushing undefined is ignored; pushing RESET_VALUE clears the buffer.getChangesSince(epoch) returns [] when the epoch is at or past the newest entry's toEpoch; the ordered diffs back to (and including) the entry whose range contains epoch, when the buffer reaches back that far; and RESET_VALUE otherwise (epoch too old, buffer empty, capacity exceeded, or cleared).capacity entries; the oldest entries are overwritten, after which queries reaching past them return RESET_VALUE.