23 KiB
@tldraw/state behavior specification
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.
1. Model and vocabulary
- A signal is a reactive value container. There are two kinds: atoms (set directly) and computed signals (derived from other signals).
- A child is anything that depends on signals: a computed signal or an effect scheduler.
- A child's parents are the signals it dereferenced during its most recent execution.
- An effect is a side-effecting function managed by an
EffectScheduler, usually viareact()orreactor(). - The global epoch is a single integer clock shared by every signal. It ticks when state changes.
- A transaction batches atom changes and defers effects; it can be rolled back.
- The reaction phase is the period during which changed atoms' effects are being run.
- A diff is a value describing the change between two sequential values of a signal.
RESET_VALUEis a sentinel meaning "no diff available; consumers must start over from the current value."
2. The epoch clock (EP)
- EP1 There is one global epoch counter shared by all signals.
- EP2 Creating a signal (atom or computed) does not advance the global epoch.
- EP3 Setting an atom to a value that is not equal to its current value (see EQ) advances the global epoch by exactly one.
- EP4 Setting an atom to an equal value does not advance the global epoch.
- EP5 Every signal exposes
lastChangedEpoch: the global epoch at the moment its value last actually changed. For a computed, recomputing to an equal value does not updatelastChangedEpoch. - EP6 Aborting a transaction advances the global epoch (in addition to any epoch advances caused by restoring atom values).
3. Equality (EQ)
- EQ1 Default equality between an old value
aand a new valuebis:a === b, elseObject.is(a, b), elsea.equals(b)ifahas a callableequalsmethod. - EQ2 Default equality is asymmetric: only the old value's
equalsmethod is consulted. Ab.equalsmethod alone has no effect. - EQ3 If an
isEqualoption is provided toatom()orcomputed(), it replaces default equality for change detection on that signal. - EQ4 When two values are "equal" by these rules, the system treats them as no change at all: no epoch advance, no history entry, no notification of children, and (for computed) the previous value object is retained —
get()keeps returning the old reference.
4. Atoms (A)
- A1
atom(name, initialValue, options?)creates an atom whoseget()returnsinitialValue. - A2
atom.set(value)makes subsequentget()calls returnvalue, unlessvalueis equal (EQ) to the current value, in which case the call is a complete no-op. - A3
atom.setreturns the atom's value after the call (the new value, or the unchanged current value). - A4
atom.update(fn)is exactlyatom.set(fn(currentValue)). It does not accept a diff. - A5
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. - A6 Atoms are independent: changing one atom never affects another atom's value, history, or
lastChangedEpoch.
5. Dependency capture (CAP)
- CAP1 While a computed signal is deriving or an effect is executing, every signal whose
get()(orgetDiffSince()) is called is captured as a parent of that computed/effect. - CAP2 A child's parent set after an execution is exactly the set of signals dereferenced during that execution. Signals dereferenced in a previous execution but not the latest one are removed (and detached).
- CAP3 Parents are recorded in first-dereference order, deduplicated: dereferencing the same signal twice captures it once.
- CAP4 Capture contexts nest. A computed dereferenced inside an effect captures its own parents in its own frame; the effect captures the computed, not the computed's parents.
- CAP5
unsafe__withoutCapture(fn)runsfnwith capture disabled and restores the previous capture context afterwards, even iffnthrows. Signals dereferenced inside it do not become parents, and changes to them alone do not re-run the enclosing computed/effect. - CAP6 Dereferencing a signal outside any capture context is allowed and captures nothing.
- CAP7 (internal) Liveness propagates transitively: when an actively-listening child captures a computed parent, that computed attaches to its own parents, recursively. Conversely, when a computed loses its last child it detaches from its own parents, recursively. A signal's
childrenset is empty whenever nothing is actively listening downstream. - CAP8 (internal)
startCapturingParents/stopCapturingParents/maybeCaptureParentreuse the child's existingparents/parentEpochsarrays (no reallocation), shrinking them in place when fewer parents are captured, and recording capture order as dereferenced.
6. Computed signals (C)
- C1 Computeds are lazy. The compute function does not run at creation time; it first runs on the first
get(). - C2 Computeds are cached. Repeated
get()calls re-run the compute function only if at least one parent'slastChangedEpochdiffers from the epoch recorded when that parent was last dereferenced. Epoch advances unrelated to the computed's parents never cause recomputation. A read is never stale: this holds for reads inside a transaction during the reaction phase, for a computed whose listening effect was scheduled but has not executed yet, and for a computed that regains a listener after its ancestors changed. - C3 A computed whose first execution captured no parents never recomputes.
- C4 The compute function receives
(previousValue, lastComputedEpoch). On the first computationpreviousValueis theUNINITIALIZEDsymbol (test withisUninitialized); afterwards it is the previous value.lastComputedEpochis the epoch at which the computed last finished computing. - C5 If recomputation produces a value equal (EQ) to the previous one, the computed keeps the previous value object and its
lastChangedEpoch; downstream children observe no change. The signal'sisEqualis never invoked for the first computation. - C6 A chain of computeds re-runs minimally: each computed in the chain recomputes at most once per relevant change, and value-level deduplication (C5) stops propagation early.
- C7
computed.isActivelyListeningis true exactly when the computed has at least one attached child (effect or actively-listening computed downstream). - C8 The
@computeddecorator on a class method makes that method behave asComputed.get()for a per-instance computed signal: cached, reactive, with options (isEqual,historyLength, etc.) honored.@computed()with no options is equivalent to@computed. Both legacy and TC39 decorator protocols are supported. A subclass may override a@computedmethod with another@computedmethod and callsuper.method(): each decoration has its own per-instance computed. - C9
getComputedInstance(obj, propertyName)returns the underlyingComputedinstance for the nearest decorated method onobj's prototype chain, creating it on demand if the method has not been called yet. Its value type is the method's return type. - C10 Using
@computedon a getter (legacy decorators only) still works but logs a one-time deprecation warning per process.
7. Errors in computed signals (CE)
- CE1 If the compute function throws, the thrown value is cached: subsequent
get()calls rethrow the same value without re-running the compute function, until a parent changes. This includes a throw from the very first computation. - CE2 Entering the error state counts as a change: downstream effects re-run (and observe the throw). Throwing again on a subsequent recomputation, while already in the error state, does not count as a change — consecutive errors do not re-trigger effects.
- CE3 Entering the error state discards the previous value: when the computed later recovers, the compute function receives
UNINITIALIZEDaspreviousValue, and the error state clears. - CE4 Entering the error state clears the computed's history buffer;
getDiffSincespanning the error returnsRESET_VALUE. - CE5 Errors do not corrupt the graph:
__unsafe__getWithoutCapture(true)suppresses the rethrow (used internally sohaveParentsChangedcan be called on a throwing graph without exploding), and the computed still participates in capture. - CE6 An effect that throws does not roll anything back: atom values (including those set in the surrounding transaction) keep their new values, and the error propagates to whoever triggered the flush.
8. History and diffs (H)
- H1 A signal has a history buffer only if
historyLengthwas passed at creation.computeDiffwithouthistoryLengthdoes nothing. - H2 When an atom with history changes, the recorded diff is chosen in priority order: the explicit
diffargument toset(value, diff), elsecomputeDiff(prev, next, lastChangedEpoch, currentEpoch), elseRESET_VALUE. Onlyundefinedmeans "not supplied"; an explicitnulldiff is recorded as a diff. - H3 When a computed with history changes, the recorded diff is chosen in priority order: the diff from a
withDiff(value, diff)return value, elsecomputeDiff(prev, next, lastCheckedEpoch, currentEpoch), elseRESET_VALUE. No entry is recorded for the first computation. As for H2, onlyundefinedmeans "not supplied". - H4 Recording
RESET_VALUEas a diff clears the entire history buffer. In particular, failing to supply a diff on a signal that has history but nocomputeDiffwipes its history. - H5
getDiffSince(epoch)returns:- the shared frozen
EMPTY_ARRAYifepoch >= lastChangedEpoch(nothing changed since); - otherwise
RESET_VALUEif the signal has no history buffer; - otherwise the ordered array of diffs covering
epoch→ now, if the buffer reaches back that far; - otherwise
RESET_VALUE(history evicted: more thanhistoryLengthchanges sinceepoch, or the buffer was cleared, orepochpredates the signal's first computation).
- the shared frozen
- H6
getDiffSincecaptures the signal as a parent, likeget(). On a computed it first brings the value up to date. - H7 History keeps recording inside transactions. On abort, the history buffers of all atoms changed during the transaction are cleared (their pre-transaction diffs are gone too —
getDiffSincefrom before the transaction returnsRESET_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. - H8
withDiff(value, diff)is only meaningful as a computed's return value; it is unwrapped soget()returnsvalue, anddifffeeds the history buffer per H3.
9. Effects: EffectScheduler, react, reactor (E)
- E1
react(name, fn)runsfnimmediately 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-runfn. - E2
reactor(name, fn)does not runfnuntil.start()..start()runs the effect if it has never run or if any parent changed while stopped; otherwise — including for an effect that captured no parents — it does not run..start({ force: true })always runs it..stop()detaches. Start/stop may be repeated, and.start()behaves the same when called from inside an effect during the reaction phase. - E3 One state change produces at most one run of a given effect, even if the change affected several of its parents (e.g. several atoms set in one transaction, or a diamond dependency graph).
- E4 An effect re-runs only when a parent's value actually changed (per EQ and C5). Equal-value sets and unrelated epoch advances do not re-run it.
- E5 The effect function receives the epoch at which the effect last ran. This enables the incremental pattern
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. - E6 With a custom
scheduleEffectoption, scheduling and execution are decoupled: when the effect would run,scheduleEffect(execute)is called instead and nothing executes until the callback invokesexecute. The initial run ofreact()is also routed throughscheduleEffect.scheduleCountcounts scheduling events, whether or not the effect later executes. - E7 If an effect is detached after being scheduled but before the deferred
executecallback runs, executing the callback is a no-op. - E8
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 sincereactoris a thin wrapper).attach()brings computed parents that went stale while nothing was listening up to date before listening to them again. - E9
maybeScheduleEffecton 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).
10. Change propagation and the reaction phase (P)
- P1 When an atom changes outside any transaction, effects run synchronously before
setreturns. - P2 Propagation reaches effects through arbitrarily deep computed chains, but only along actively listening edges. Effects (or computeds) that nothing is listening to are not visited.
- P3 Each child in the graph is traversed at most once per epoch during a flush, even when reachable through multiple paths.
- P4 Effects may set atoms. Changes made during the reaction phase do not interrupt the current pass: affected effects are collected and run after the current pass completes, repeatedly, until the system quiesces.
- P5 If that loop fails to quiesce after 1000 passes, the flush throws
Reaction update depth limit exceeded. An effect that unconditionally writes to one of its own parents hits this limit. - P6 An effect that sets atoms inside a transaction (or
transact/transactioncall) during the reaction phase gets the same deferral: the inner transaction's effects are folded into the current reaction phase rather than flushed reentrantly. - P7 Computeds read during the reaction phase are correct: an effect that sets an atom and then reads a computed depending on it sees the updated value (within the same pass), including when both happen inside a transaction the effect opened (T2).
- P8 An effect's run is never re-entered by a flush. A set during a run that happens outside the reaction phase (the first run of
react(),reactor.start(), or a directexecute()) still flushes synchronously per P1, but if that flush reaches the running effect itself, the effect runs again after the current run finishes rather than nesting a second run inside it. As in the reaction phase, it keeps running while its parents keep changing (subject to the P5 limit), skips the extra run if none of the parents it captured have changed by then, and does not run again if it was detached during the run (E7). Callingexecute()re-entrantly from inside the effect is not supported: both runs would share oneparents.
11. Transactions (T)
- T1
transaction(fn)starts a new transaction, always.transact(fn)joins the current transaction if one exists, else behaves liketransaction(fn). Both returnfn's return value — including when the transaction rolls back. - T2 Inside a transaction there is no isolation: atom
sets are visible immediately to subsequent reads, and computeds recompute on demand against in-transaction values. - T3 Effects do not run during a transaction. They run once, synchronously, when the outermost transaction commits, per E3.
- T4
fnreceives arollbackcallback. 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. - T5 If
fnthrows, the transaction aborts as in T4 and the exception propagates. - T6 An aborted transaction still flushes effects: effects whose parents went through a change-and-restore round trip are scheduled, observe the restored values, and per E4 may run. Effects never observe intermediate in-transaction values.
- T7 Nested
transactioncalls 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), including when the transactions run inside an effect during the reaction phase. - T8 Because
transactjoins rather than nests, a throw inside an innertransactdoes not restore anything by itself; if it propagates out of the outermost transaction, T5 applies there. - T9 Abort clears the history buffers of every atom changed in the transaction (H7).
- T10 Mismatched transaction boundaries (committing a transaction that is not the innermost) throw
Transaction boundaries overlap.
12. Async transactions: deferAsyncEffects (AT)
- AT1
deferAsyncEffects(fn)runs the asyncfnin a transaction context: atom changes are visible immediately to reads (T2 applies), but effects are deferred until the async transaction completes. - AT2 It throws (rejects) if called while a synchronous transaction is in progress. Synchronous
transaction/transactcalls inside the async body are fine and nest normally. - AT3 If
fnrejects or throws, all changes made during the async transaction are rolled back and the error propagates. The rollback is atomic from the point of view of effects (T6): they run once, after every atom has been restored. - AT4 Calling
deferAsyncEffectswhile 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 acrossawaitboundaries between concurrent processes (no AsyncContext); the grouping of effects is the guarantee, not isolation. - AT5 A
deferAsyncEffectscall kicked off during the reaction phase waits for the reaction phase to finish before starting. - AT6 The returned promise resolves to
fn's return value.
13. Debugging aids (D)
- D1 Every signal and effect takes a
namefor debugging. Names need not be unique and have no behavioral effect. - D2
whyAmIRunning()throws if called outside a computed or effect execution. - D3 When called inside one, the next execution of that computed/effect logs (via
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.
14. Type guards and module duplication (G)
- G1
isAtom(v)is true exactly for values created byatom();isComputed(v)exactly for computed signals (including instances behind decorated methods);isSignal(v)isisAtom(v) || isComputed(v). All three return false fornull,undefined, plain objects, functions, and each other's instances. - G2 The library's classes and global state (epoch counter, transaction state, capture stack, class identities) are registered as
globalThissingletons. 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.
15. localStorageAtom (LS)
- LS1
localStorageAtom(name, initialValue, options?)returns[atom, cleanup]. The atom's starting value is the JSON-parsed localStorage entry fornamewhen one exists, elseinitialValue. - LS2 A corrupted (unparseable) localStorage entry is deleted, and
initialValueis used. An empty-string entry is treated as absent. - LS3 The atom's value is written to localStorage as JSON on creation and after every change.
- LS4 A
storageevent for the same key updates the atom with the parsed new value; astorageevent withnewValue: nullresets the atom toinitialValue; an unparseablenewValueand events for other keys are ignored. - LS5
cleanup()stops localStorage writes and storage-event handling. The atom itself keeps working as a plain atom. Atomoptions(equality, history) pass through per A/H rules. - LS6 Without a
window(Node, SSR)localStorageAtomdoes not throw: it behaves as a plain atom withinitialValueandcleanup()is a no-op.
16. ArraySet — internal (AS)
ArraySet is the dependency-set data structure. Contract: it behaves as a set under add/remove/clear/has/size/isEmpty/visit/iteration.
- AS1
addreturns true and inserts when the element is absent; returns false and does nothing when present.removereturns true and deletes when present; returns false otherwise. - AS2
visitand[Symbol.iterator]yield each element exactly once;has,size, andisEmptyare consistent with the elements yielded. - AS3 The implementation stores up to
ARRAY_SIZE_THRESHOLD(8) elements in an array, promoting to aSeton overflow. Behavior is identical in both modes and across the promotion boundary, including after interleaved adds, removes, and clears. - AS4 A fresh ArraySet allocates no backing storage until the first
add. Every query on it (has,size,isEmpty,visit, iteration) answers as empty, andremoveandclearare no-ops that leave it usable.
17. HistoryBuffer — internal (HB)
HistoryBuffer is the circular diff store behind H1–H5.
- HB1
pushEntry(fromEpoch, toEpoch, diff)stores a diff covering the epoch range. PushingRESET_VALUEclears the buffer, and so does pushingundefined("no diff available"): skipping the entry instead would leave a gap, and a later query from before the gap would return an incomplete diff list. - HB2
getChangesSince(epoch)returns the shared frozenEMPTY_ARRAYwhen the epoch is at or past the newest entry'stoEpoch; the ordered diffs back to (and including) the entry whose range containsepoch, when the buffer reaches back that far; andRESET_VALUEotherwise (epoch too old, buffer empty, capacity exceeded, or cleared). - HB3 The buffer holds at most
capacityentries; the oldest entries are overwritten, after which queries reaching past them returnRESET_VALUE.