Version 1.3.4
What's Changed
Fixed
recomputeTaskleft a Task permanently stuck after a synchronous throw (src/graph.ts): The earlyreturnin thecatchblock skipped bothsetState(node.pendingNode, true)andnode.flags = FLAG_CLEAN, leavingFLAG_RUNNINGset. Every subsequent read then threw a spuriousCircularDependencyError(via theFLAG_RUNNINGguard inrefresh()), even though no cycle existed. Now thecatchclearsFLAG_RUNNINGand resets pending, so the node stays recoverable and subsequent reads report the original error.Listsilently droppedundefinedelements, causing alength/get()mismatch (src/nodes/list.ts): The init loop anddiffArraysskippedundefinedviaif (val === undefined) continue, leavingkeyssparse (keys.length> actual signal count) whilenode.valueretained the original array with theundefined. Nowundefinedis rejected withNullishSignalValueError, consistent withnull.createCollectionapplyChangessilently overwrote duplicate keys (src/nodes/collection.ts): The add-path calledsignals.set(key, ...)without checking for an existing key, orphaning the previous signal's subscribers. Now throwsDuplicateKeyError, matchingList.addandStore.add. Additions are staged into aMapfirst, so a duplicate anywhere in the batch — including against another item in the same batch — leavessignals/keys/itemToKeyuntouched instead of partially applied.Store.set()leaked dependency edges when called inside an effect (src/nodes/store.ts):buildValue()was called withoutuntrack(), so child.get()calls created edges from each childStateto the active effect, causing over-broad re-runs. Now wrapped inuntrack(), mirroringList.Store.set()misrouted primitive↔array type changes (src/nodes/store.ts): The type-change checkisRecord(val) !== isStore(signal)returnedfalsefor arrays (not records), soState<number>→ array fell intosignal.set(array)instead of routing throughaddSignal/createList. Now compares shape categories (list/store/state).DEEP_EQUALITYstack-overflowed on cyclic input and rejected equalDate/RegExp(src/graph.ts):deepEqualhad no cycle guard and treatedDate/RegExpas non-records (returningfalse). Now has a path-scopedWeakSetguard — entries are removed in afinallyonce each comparison returns, so only genuine cycles on the active recursion path resolve as equal, not every object visited during the call — plus explicitDate(getTime) andRegExp(source+flags) branches. See ADR-0016.valueStringthrew insideErrorconstructors on circular values (src/util.ts):JSON.stringifythrows on circular references, masking the original validation failure. Now wrapped in try/catch with aString(value)fallback.Slot.set()stack-overflowed on circular delegation (src/nodes/slot.ts): Mutual delegation (A→B→A) infinite-looped. Now detects the cycle and throws a descriptive error.DuplicateKeyErrordropped falsy values from its message (src/errors.ts): The truthy checkvalue ? ... : ''omitted0,'', andfalsefrom the message. Now checksvalue != null.- Non-
asynccallback returning aPromisewas silently misclassified as aMemo(src/graph.ts,src/errors.ts):createComputed/createSignalroute toMemoorTaskby checking whether the callback is declaredasync(isAsyncFunction) — a check made on the callback itself, before it ever runs. A callback that forgotasyncbut still returned aPromise(e.g.createComputed(() => fetch(url).then(r => r.json()))) was created as aMemo, which then cached thePromiseobject itself as its value;equals/guardran against thePromise, not the resolved data. NowrecomputeMemo()checks the computed value withnext instanceof Promiseand throws a newPromiseValueErrorinstead — this is the shared recompute path for Memo, Slot, and the internal structural nodes of List/Store/Collection, so the check coversSlot/SlotDescriptor.get()misuse too.
Changed
- Composite signal accessors now subscribe to structural changes: The direct-lookup methods (
at(),byKey(),keyAt(),indexOfKey()) and theSymbol.iteratoronListandCollection, plus theSymbol.iteratoronStore, previously created no graph edge — reading them inside an effect or memo silently failed to re-run when keys were added, removed, or reordered. Each now callssubscribe()(orensureFresh()forderiveCollection, whose node can be stale from upstream tracked changes), establishing the same O(1) structural-consumer edge thatkeys(),length, andget()already create. The defensivekeys()pre-read workaround is no longer required, andlist.replace(key, value)now reaches iterator-subscribers (previously silent). See ADR-0015. Migration: effects that read only these accessors will now re-run on structural changes where they previously did not — the intended fix, but a behavior change to be aware of. This is not type- or API-surface-breaking. Storeper-property access stays granular:Store.byKey()and the proxy property access (store.prop) deliberately remain untracked for structural changes, because proxy reads are already granular —store.namereturns the childState, whose.get()forms a property-level edge. Adding a structural edge would makestore.set({ name, age })spuriously re-run thenameeffect. The only untracked accessors in the library are now these Store per-property paths; whole-store traversal (get(),keys(), iterator) tracks consistently. The principled line within Store is whole-store vs per-property.List.replace()is batched internally: The item-signalset()and the structural node propagation are now wrapped inbatch()so subscribers holding both an item-level edge and a structural edge (e.g. an effect callingbyKey(k).get()) flush once instead of up to three times. This was a latent redundancy exposed by the accessor-tracking change above.isComputedreturn type corrected (src/signal.ts): Wasvalue is Memo<T>despite acceptingTasks. Nowvalue is Memo<T> | Task<T>, reflecting that aTaskdoes not satisfyMemo<T>'s shape (noisPending/abort).
Full Changelog: v1.3.3...v1.3.4