SignalTree error codes

August 11, 2026 · View on GitHub

Every SignalTree error and dev-mode warning carries a stable, greppable code in brackets, e.g. [ST2001]. Search the code — in your editor, a stack trace, or this file — to find the cause and fix. Codes are append-only and never reused.

  • ST1xxx — core: tree creation, updates, lifecycle, enhancers
  • ST2xxx — entity collections and markers (mostly dev-mode guardrails)

Dev-mode warnings (ST20xx) fire only when ngDevMode is true; they never run in production builds. They exist to catch mistakes — especially in AI-generated code — early.


ST1xxx — core / update / enhancer

CodeMeaningCommon cause → fix
ST1001null/undefinedA value that must be defined was null/undefined. Check the path/argument.
ST1002circular referenceState contains a cycle. SignalTree state must be a tree (acyclic). Break the cycle or store an id reference.
ST1003updater invalidAn updater passed to .update() wasn't a function (current) => next.
ST1004lazy fallbackLazy materialization fell back to eager — informational.
ST1005signal creation failedA leaf signal couldn't be created — usually a non-serializable/proxy value at a leaf.
ST1006update path not foundA merge/update targeted a path that doesn't exist in the tree shape.
ST1007update failedAn update threw mid-apply; see the chained error.
ST1008rollback failedA transactional rollback couldn't restore prior state.
ST1009cleanup errortree.destroy() / teardown threw; usually safe to ignore.
ST1010unknown presetAn unrecognized config preset name was passed.
ST1011strategy selectInternal: update-strategy selection issue.
ST1012tree destroyedOperating on a tree after destroy(). Create a new tree.
ST1013update transactionTransactional update issue; see chained error.
ST1014batching disabledA batching API was called without the batching() enhancer. Add .with(batching()).
ST1015memoize disabledRemoved in 9.0.1 — use Angular computed() directly.
ST1016middleware missingMiddleware API used without the providing enhancer.
ST1017entity helpers missingEntity helper used without the entity feature available.
ST1018time travel missing.undo()/.redo() used without the timeTravel enhancer. Add it from @signaltree/core.
ST1019optimize missingOptimization API used without @signaltree/enterprise.
ST1020update optimized missingupdateOptimized() requires the enterprise() enhancer.
ST1021cache missingCache API used without the providing enhancer.
ST1022performance disabledPerformance API used without it enabled in config.
ST1023enhancer order failedEnhancers couldn't be ordered — check declared requires/provides.
ST1024enhancer cycleTwo enhancers require each other. Break the dependency cycle.
ST1025enhancer requirement missingAn enhancer requires another that isn't applied. Add the prerequisite .with(...).
ST1026enhancer provides missingAn enhancer declared a capability it didn't provide.
ST1027enhancer failedAn enhancer threw while applying; see chained error.
ST1028enhancer not a functionA value passed to .with() wasn't an enhancer function.
ST1029no Angular context (effect)effect() was created outside an injection context. Call within a constructor/runInInjectionContext.
ST1030no Angular context (subscribe)Subscription created outside an injection context.
ST2031a node held across changeId resolves undefinedchangeId(from, to) deliberately drops the old per-entity signal rather than aliasing it — an alias would be shared with a future addOne({ id: from }), which is a worse failure than a stale node. So a node held from byId(oldId) reads undefined forever, and did so silently. Re-read with byId(newId), or hold the id and call byId(id()) at the point of use rather than holding the node across a rekey. The shape that hits it is a long-lived selectById(tempId) closing over the pre-server id during an optimistic create. Dev-only, reported once per retired id.
ST2032timeTravel({ maxHistorySize }) cannot support undomaxHistorySize is a buffer LENGTH, not a step count: N retained entries yield N-1 undo steps, because the oldest retained entry is the state you land ON rather than a step you spend. MEASURED after 10 writes — omitted: 10 steps, 5: 4, 2: 1, 1: 0, 0: 0. So 0 (which reads as "no limit") and 1 (which reads as "one step") both left canUndo() permanently false, -1 additionally drove getCurrentIndex() to -1, and NaN was silently UNBOUNDED because length > NaN is never true. A silently dead undo button is the same failure class as a phantom step — the API reports undo is available and it does nothing. Any value below 2, or non-finite, now falls back to the default of 50 and reports this. Dev-only.
ST1031invalid security configsecurity was given a raw config object. Pass security(config) from @signaltree/core/security — the validator is injected so it tree-shakes when unused.
ST1032lazy not injecteduseLazySignals: true does nothing on its own. Pass lazy: lazy() from @signaltree/core/lazy; the machinery is injected for the same reason.

ST2xxx — entity / markers (dev-mode guardrails)

CodeMeaningCause → fix
ST2001entityMap entity has no idEntities resolved to null/undefined id, so they collide under one key. Give entities an id field, or entityMap({ selectId: (e) => e.yourKey }).
ST2002entityMap unknown methodA method from another library (Akita .upsert/.add, Elf .addEntities/.setProps, RxJS .next) was called. Use the SignalTree equivalent named in the warning (upsertOne, addMany, …).
ST2003ref-identical write skippedA merge write passed a value reference-identical to the current value — a no-op. You likely mutated an object/array in place; return a NEW reference (spread/slice/map) so the change is observed.
ST2004entityMap raw load function(v12.0.0+) entityMap({ load: fn, staleTime, swr, tags, … }) with a raw function on load is rejected — wrap it with loader() from @signaltree/core and move the loader-family options (staleTime/swr/tags/persist/equal/lazy) into its second argument: entityMap({ load: loader(fn, { staleTime, tags }) }).
ST2005signalForm + marker asyncValidatorsBridging a form() marker that has asyncValidators configured into signalForm() throws — the marker's async path and Signal Forms' validateAsync/validateHttp can't both drive one form. Pick one authority: remove asyncValidators from the marker and use Signal Forms' validateAsync/validateHttp, or don't bridge and drive the form through the marker's own validateField()/submit().
ST2006form() history not from history()(v13.0.0+) form({ history: <value> }) was given something other than history()'s return value. Import history from @signaltree/core and pass history({ capacity, exclude }) — a raw config object on history is not accepted.
ST2007derived value dropped(13.2.0+) A value in a .derived(...) object was neither a signal, a derived marker, nor a plain object to merge, so it was not added to the tree. Most often this means your app loads two copies of @angular/core: each has its own Symbol(SIGNAL), so isSignal() inside @signaltree/core rejects a computed() your code created, and every derived value is silently discarded. The warning distinguishes the two cases. Fix the duplicate in your bundler (Vite: resolve: { dedupe: ['@angular/core'] }; Jest: moduleNameMapper) or hoist @angular/core to one version.
ST2008value omitted from snapshot(13.4.0+) tree()/unwrap() skipped a value because it is a function that is neither an Angular signal nor a node accessor, so the key is missing from the snapshot — and from anything built on it (serialize, persistence(), devtools, audit). Usually a materialized marker that did not conform to either shape.
ST2009applyState replaced a live value(13.4.0+) applyState (the devtools state-replay path) overwrote a callable that is neither a signal nor a node accessor with a raw value. The signal at that path is gone and reading it will throw.
ST2010write to a key outside the initial shape(13.4.0+) A tree's signal graph is built from the object passed to signalTree(). A write to a key that was never in that shape has no signal to land on and is discarded. Add the key to the initial state — a declared-but-optional TypeScript property is not enough, the key must be present at construction.
ST2011marker in a lazy tree(13.4.0+) A marker (stored(), status(), entityMap(), form(), …) was reached inside a lazy tree. Lazy trees resolve values through a proxy that never runs marker materialization, so the marker stays a placeholder: unusable as a signal and dropped from every snapshot. Use markers only in eagerly-built trees, or move the marker out of the lazy subtree.
ST2014write to a branch discardedA branch position was written with a non-object value (tree.$.user.set(null), or an updater returning null/a Promise — the forgotten await). A branch has no signal of its own, so the write goes nowhere. Write the leaves, or use a marker if that position should hold a value.
ST2016prototype-pollution key rejectedA state key named __proto__, constructor or prototype was rejected at construction. If the payload came from JSON.parse, this is an attempted prototype-pollution (CWE-1321) and the key is dropped deliberately.
ST2017enhancer forward target missingAn enhancer built a new tree object but could not find the method it was forwarding to. Enhancers must copy property DESCRIPTORS (copyTreeProperties), not Object.assign — every tree method is non-enumerable, so Object.assign silently drops all of them.
ST2018collection stored as a plain array leaf(13.5.0+) A leaf holds 32+ objects carrying a stable id/_id/uuid/key. As a plain array leaf every update rebuilds the whole array and every equality check walks it. Measured on 1000 updates to a 50k collection: array leaf 49.80 ms vs entityMap 1.63 ms (~30x), which puts the array leaf at parity with the immutable stores SignalTree otherwise beats. Fix: entityMap({ selectId: (e) => e.id }). If the array is genuinely read-only or always replaced wholesale, that is fine — wrap it in compared() with your own comparator to silence the warning. Dev-only, deduped per key, bounded 64-element sample.
ST2019invalid compared() comparator(13.5.0+) compared(value, equal) was given a non-function comparator, or byKeys() was called with no keys (with none, every value compares equal and no write would ever notify).
ST2020duplicate stored() key(14.0.0+) Two stored() markers were created for the same storage key. Each call makes its OWN signal — they do not observe each other, so one holds a stale value and they race on write, last-writer-wins. Create the marker once and share the tree node, or use distinct keys. Not merged automatically because two calls may carry conflicting defaultValue/version/migrate with no correct merge.
ST2021marker inside an array(14.0.0+) A marker (stored(), status(), entityMap(), …) was found inside an array. Array elements are never traversed, so the marker is never materialized: it stays a raw object, it is not a signal, and writes to it are lost. Markers belong at object positions; for a keyed collection use entityMap({ selectId }).
ST2022marker registered without declaring state(14.0.0+) registerMarkerProcessor() was called with neither snapshot nor transient: true, so nothing ever answered "what of this marker is state?" Its value is dropped from every snapshot — tree(), persistence(), devtools, audit and undo/redo — silently. Pass { snapshot, hydrate }, or { transient: true } if it deliberately has no restorable state. Checked at registration rather than at materialization, because materializeMarkers swallows create() throws and a guard there would fail open. Affects custom markers only.
ST2023marker can be snapshotted but never restored(14.0.0+) A marker declares snapshot but no hydrate, and its node is not a writable signal. It is captured by every snapshot consumer and every attempt to write it back is silently discarded, so tree(tree()) loses its value. [ST2022] cannot see this — a snapshot hook satisfies it. Add a hydrate hook, or transient: true if it genuinely is not restorable. Silent when the node IS a writable signal, since the ordinary write path handles it with no hook. Affects custom markers only; no built-in trips it.
ST2024hydrate payload was malformed(14.0.0+) A marker's hydrate was handed a payload it could not read — entityMap with no all array, or status() with an unrecognised state string — so the node was left unchanged rather than throwing (one bad key should not kill an app) or resetting (that would silently discard state). This is a PAYLOAD problem, not a registration one: usually a pre-2.0.0 snapshot, where entityMap emitted a map that JSON renders as {} and the entities were never in the payload at all. Previously reported as [ST2022], which is a different condition entirely.
ST2025an onTreeError listener threw(14.0.0+) A listener registered with onTreeError threw while being notified. The ORIGINAL error was still handled normally — this reports the listener failing, not the operation. Swallowed deliberately: letting it propagate would make adding error reporting a source of errors, and it would surface at whichever marker happened to report first, which is the least debuggable outcome available. Fix the listener; the tree is fine.
ST2026an inline predicate is defeating the where/find cache(14.0.0+) where()/find() memoise per predicate IDENTITY, so the natural template form @for (row of tree.$.rows.where(r => !r.done)(); …) allocates a NEW arrow every change-detection cycle, misses the cache every time and re-scans the collection — measured at 75x a hoisted predicate over 1,000 entities (0.27ms vs 20.54ms). It is NOT a leak: the cache is a WeakMap and 50,000 inline calls retain ~0MB after forced GC, which is exactly why it needs a diagnostic — nothing grows, nothing breaks, the app is simply slow forever. Detected by source text plus RATE: byte-identical source is necessary but not sufficient, because v => v.x > threshold rebuilt when threshold changes looks identical too. Counting alone eventually accuses both, and "hoist it" is the wrong advice for the second — so it now takes 12 distinct identities within 2 seconds (~6/second, far above anything user-driven and far below a frame loop). Hoist the predicate to a class field or module constant. Dev-only, warned once per source.
ST2027a write changed nothing, and not the reference kind(14.0.0+) The new value is a DIFFERENT object that deep-equals the current one, so the whole structure was compared and the write discarded. The shape a re-fetched payload takes. Distinct from [ST2003], which covers re-setting the identical REFERENCE: this one is the expensive twin, because deepEqual cannot short-circuit and must walk everything to conclude nothing changed — ~2.8ms on a 50k array, to do nothing. Invisible by construction: no error, no notification, no state change, only slowness. It corrupted this repo's own benchmarks twice before anyone noticed. Skip the write when the data is unchanged, or use compared() to pick a cheaper equality. Gated to values of 32+ elements/keys, deduped per path, dev-only.
ST2028an edit session holds a value structuredClone cannot copy(14.0.0+) createEditSession clones with structuredClone, which THROWS on a function — so one callback anywhere in the edited value drops the whole object onto the fallback. That fallback used to be JSON.parse(JSON.stringify(...)), which is silent corruption of an undo stack: undo handed back Date as a string, Map/Set as {}, undefined keys gone, and the callback itself gone. It is now a type-aware walk that preserves Date, Map, Set, RegExp, undefined, cycles, and class prototypes (an ApiError comes back an ApiError, with its non-enumerable message intact). What remains is narrow and deliberate: a FUNCTION is shared by reference between history entries rather than copied — correct, since a function has no state to restore — and a class instance's private # fields are not carried across. Keep stateful non-cloneables out of the edited object. Dev-only, reported once.
ST2029time travel is retaining a lot of collection pointers(14.0.0+) A history entry holds the tree's snapshot, and a collection's snapshot is one POINTER PER ENTITY, rebuilt whenever the collection changes — so entries x width x ~8 bytes is the FLOOR for touching that collection at all, and every write to it is O(collection). MEASURED over 50 recorded writes via tools/bench-retention-arms.mjs: 1,000 rows = 0.51MB, 10,000 = 3.95MB, 50,000 = 19.38MB. It is a floor and not a worst case — one changed row costs the same as fifty different ones, and each CHANGED row adds ~40 bytes on top, so changing all 50,000 retains 114.77MB (5.9x the floor). Warns past ~500k retained pointers (~4MB). Judged on RETENTION, not row count, so a big collection with a short history and a small one with a long history are held to the same standard — a row-count threshold gets both wrong. Checked at RECORD time, sampled every 16 entries: an app attaches timeTravel() when it builds the tree and the rows arrive later from a fetch, so an attach-time check sees an empty collection every time. If a collection should persist but not be undoable, pass entityMap({ recordHistory: false }) — MEASURED, that makes retention INDEPENDENT of collection width (~0.15MB at 1k, 10k and 50k alike), not merely smaller; if neither, use transient: true. Note the tradeoff: undo() will NOT revert an excluded collection. Dev-only, reported once.
ST2030guardrails could not copy a container in state(14.0.0+) @signaltree/guardrails answers "did anything change" from snapshot REFERENCE identity, which is exact and O(1) — tree() returns the identical object when nothing changed. The one thing that escapes it is a container mutated IN PLACE (tree.$.rows().push(x) notifies nothing), so guardrails copies each array/Map/Set/Date it finds and re-checks those. structuredClone throws on a function or class instance, so a container holding one cannot be copied: its SHAPE stays watched (anything changing length or size is still caught, at O(1)) but an in-place edit of its CONTENTS is not. Scoped to that one container — everything else in state is unaffected. Contents watching is budgeted in AGGREGATE (5,000 elements across the whole tree, ~4µs per poll), not per container — fifty containers of 999 elements each pass a per-container cap individually and cost 50,000 comparisons twenty times a second. Past the budget a container is shape-watched by design rather than by failure. Remove the non-cloneable field; rules.noFunctionsInState() locates it. Dev-only, reported once.

Compile-time symptoms (not ST codes)

Some mistakes surface as a TypeScript error at author time, so they never get an ST code — but this page is where people search, so they're routed here. (The ST tables above stay append-only; this section is deliberately separate from that numbering.)

SymptomCause → fix
TS2339: Property 'set' does not exist on type 'NodeAccessor<…>'You initialized an object state key as a plain object ({} / {} as Dto), which makes it a nested node (fields become individual leaves), then tried to .set() it as a single value. To store an object as one settable leaf, initialize it null with the object type: firmware: null as FirmwareDto | null — then .set(dto) / () work, and consumers default with ?? {}. Full discussion: Myth 19 · typing patterns