Releases: zeixcom/cause-effect
Releases · zeixcom/cause-effect
Version 1.3.3
What's Changed
Changed
List.buildValue()uses apushloop instead ofmap/filter: Eliminates the intermediate(T | undefined)[]allocation thatmap()produced beforefilter()could removeundefinedentries. Now builds the result in a single pre-allocated pass.List.sort()uses an imperative loop: Replaceskeys.map(key => [key, signals.get(key)?.get()]).sort(...).map(([key]) => key)with a singleentriesbuild loop and a separatenewOrderaccumulation loop, removing two intermediate array allocations.List.splice()andList.replace()use boolean flags for change detection: ReplacesObject.keys(changes.change).length(iterates all keys to count) with an early-exitfor...inloop that sets a flag on the first key found.List.add()drops redundantkeys.includes(key)guard: The precedingsignals.has(key)check already throwsDuplicateKeyErrorfor duplicate keys;keys.includes(key)was unreachable dead code.diffArrayssplit intodiffPositionalfor non-content-based keys: Positional-key lists now take a dedicated fast path (diffPositional) that walks both arrays in a singleO(n)pass with noMaporSetallocation. Content-based diffing retains theMap/Setapproach for key-stability tracking.syncKeysinderiveCollectionreducesSetallocations: Previously constructed twoSets (new Set(keys)andnew Set(nextKeys)). Now constructs onlynextSet = new Set(nextKeys)for deletion detection and uses the existingsignalsMapdirectly (signals.has(key)) to decide whether a key needs to be added.Store.buildValue()andStore[Symbol.iterator]iterateMapentries directly:buildValuereplacessignals.forEach((signal, key) => ...)withfor (const [key, signal] of signals). The iterator replacesArray.from(signals.keys())and a secondarysignals.get(key)lookup with a singlefor...ofoversignalsentries, eliminating an intermediate array allocation.Slottype assertion cleanup: Removedas anycasts inisSignalOrDescriptorandcreateSlot.set; usedreturn void delegated.set(next)in the Slot-to-Slot delegation path to avoid implicitly returning the inner call's result.
New Contributors
- @enesgules made their first contribution in #46
Full Changelog: v1.3.2...v1.3.3
Test Trusted Publishing Workflow
What's Changed
- chore(github-actions): setup workflow for trusted publisher by @estherbrunner in #50
- Revise SECURITY.md for clarity on support and reporting by @estherbrunner in #51
- Feature/trusted publisher workflow by @estherbrunner in #52
- chore: bump version to 1.3.4-beta.3 by @estherbrunner in #53
- chore: switch install ci action to Bun by @estherbrunner in #54
- chore: install devDependencies as well (for bun types) by @estherbrunner in #55
- chore: add .npmignore by @estherbrunner in #56
- chore: remove NPM_TOKEN from publish workflow by @estherbrunner in #57
- chore: add repository field to package.json by @estherbrunner in #58
Full Changelog: v1.3.3...v.1.3.4-beta.8
Version 1.3.2
What's Changed
Fixed
- Stale value and lost propagation after all consumers of a
SlotorMemodisconnect and reconnect: Previously, when the lastEffectunsubscribed from aSlot(or any intermediateMemoNode),unlinkcorrectly cascaded into theMemoNodeviatrimSources— pruning the upstreamState → MemoNodeedge — but leftflagsasFLAG_CLEAN. On reconnect,refresh()sawFLAG_CLEANand returned immediately without callingrecomputeMemo: the source edge was never re-established, the node returned its stale cached value, and subsequentState.set()calls did not propagate at all (the source's sink list no longer contained theMemoNode). NowsinkNode.flags |= FLAG_DIRTYis set after the cascadetrimSourcesinunlink(src/graph.ts). The nextrefresh()triggersrecomputeMemo, which re-runsfnwithactiveSink = node, re-links the upstream edge vialink(), and returns a fresh value. Downstream propagation then works correctly for the lifetime of the new consumer.
Full Changelog: v1.3.1...v1.3.2
Version 1.3.1
What's Changed
Added
createScopenow accepts an optionalScopeOptionssecond argument;{ root: true }creates a root scope that is not registered on the current parent owner – the returneddisposeis the sole teardown mechanism. Export newScopeOptionstype.
Changed
- Improved type inference for
createListandcreateCollectionwhen providing a customcreateItemfactory (e.g.createStore). The generic type of the returned item signal is now properly inferred without requiring type assertions.
Full Changelog: v1.3.0...v1.3.1
Version 1.3.0
What's Changed
Added
SlotDescriptorsupport for bi-directional derivations:createSlot()andSlot#replace()now accept a duck-typedSlotDescriptor<T>object ({ get(): T, set?(next: T): void }) in addition to aSignal<T>. This allows establishing stable, native reactive edges for derived{ get, set }pairs without the need for an intermediaryComputedsignal, which prevents edge corruption during cascading graph updates. If a descriptor omits thesetfunction, the slot safely behaves as read-only.- Configurable
itemEqualsforListandCollection: Added anitemEqualsoption to bothListOptionsandCollectionOptions. It defaults toDEEP_EQUALITY. This prevents spurious reactive propagation when spread-based updates (list.replace(key, { ...item.get(), field: newValue })) produce structurally identical items. - Configurable
createItemfactory forList: Added acreateItem?: (value: T) => MutableSignal<T>option toListOptions, bringing it to parity withCollectionOptions. This allows backing list items with custom mutable signals, such ascreateStorefor granular, per-property reactivity within a list.Listmethods likeat(),byKey(), and its iterator now correctly returnMutableSignal<T>instead of strictlyState<T>.
Full Changelog: v1.2.1...v1.3.0
Version 1.2.1
What's Changed
Fixed
match()stalehandler not firing on re-fetches: Previously,staleonly fired on the first effect run when a task had a seeded value and its initial fetch was in progress. On subsequent re-fetches (when a task source dependency changed), the effect silently becameFLAG_CLEANwithout running:propagate(taskNode)sent onlyFLAG_CHECKto downstream effects, sorefresh(effectNode)calledrefresh(taskNode)→recomputeTask(), which returned synchronously with no value change — the effect saw noFLAG_DIRTYand was cleaned without executing. NowrecomputeTask()callssetState(node.pendingNode, true)immediately after the synchronous fn preamble. This propagatesFLAG_DIRTYto subscribed effects mid-refresh, causing the source-check loop inrefresh()to break and run the effect, which then routes tostaleas expected.task.isPending()is now reactive: Previously a plain boolean read (!!node.controller) that created no graph edges. Now backed by an internalpendingNode: StateNode<boolean>and subscribed viamakeSubscribe— callingisPending()inside a reactive context (effect,match()) creates a dependency edge. The effect re-runs when the task transitions from not-pending to pending (fetch starts) in addition to when it transitions from pending to not-pending (fetch resolves, handled by value propagation). Effects that do not callisPending()are unaffected. Promise.then/.catchhandlers resetpendingNodetofalseinside abatch()alongside any value propagation to prevent double effect runs.
Full Changelog: v1.2.0...v1.2.1
Version 1.2.0
What's Changed
Added
stalehandler formatch(): BothMatchHandlers<T>andSingleMatchHandlers<T>now accept an optionalstale?: () => MaybePromise<MaybeCleanup>branch. It fires when all signals have a retained value but at least oneTasksignal is currently executing (isPending() === true). Routing precedence isnil>err>stale>ok; omittingstalefalls back took, showing the retained value unchanged while the task re-fetches. Any cleanup returned bystaleis registered on the owner and runs before the next handler dispatches — the right place to remove a refresh indicator or dim overlay. In React Query terms:nilmaps toisLoading(no data yet);stalemaps toisFetchingwith existing data.isSignalOfType<T>(value, type)utility: New exported function that replacesisObjectOfTypefor signal type guards. Checksvalue != null && value[Symbol.toStringTag] === typedirectly — zero string allocations, O(1). All eight internalis*()guards (isState,isMemo,isTask,isSensor,isSlot,isStore,isList,isCollection) now use it.DEEP_EQUALITYequality preset: New exported constant for deep structural comparison of plain objects and arrays. UsesObject.isas a fast path, then recursively compares array elements by index and own enumerable keys of plain-object records (Object.getPrototypeOf(v) === Object.prototype). Non-plain objects (class instances,Map,Set) are never structurally equal unless they are the same reference. Pass to theequalsoption to suppress propagation when a signal holding an object or array recomputes to a structurally identical value.DEFAULT_EQUALITYexported fromindex.ts: The===-based equality preset was already used internally throughout the library but was not part of the public API. It is now exported, allowing callers to restore the default explicitly when composing or selectively overridingSignalOptions.
Changed
isSignaluses a module-levelSetwith directSymbol.toStringTagaccess: Previously allocated two strings per call viaObject.prototype.toString.call(value).slice(8, -1)and scanned an inline array withArray.includes(). Now checksSIGNAL_TYPES.has(value[Symbol.toStringTag])— one hash lookup, zero allocations,Setbuilt once at module load.isRecorduses a prototype check instead ofObject.prototype.toString: PreviouslyObject.prototype.toString.call(value) === '[object Object]', which returnstruefor class instances without a customSymbol.toStringTag. Now checksObject.getPrototypeOf(value) === Object.prototype, which excludes class instances. AffectscreateSignalandcreateMutableSignal: a class instance with noSymbol.toStringTagpreviously resolved to aStore; now it falls through tocreateState. Class instances are not plain records, so this is the correct behavior.isEqual/DEEP_EQUALITYcycle detection removed: Previously, the deep equality function inlist.tsandstore.tsallocated aWeakSeton everyList.set()/Store.set()call, added both operands before recursing, and threwCircularDependencyErroron a circular reference. Thetry/finallyblock cleaned up theWeakSetentries after each call. All of this is removed — the implementation is now plain recursion (deepEqualingraph.ts) with no allocations. Circular data causes a stack overflow rather than a thrown error. Signal values are expected to be plain JSON-like data; circular references are a programming error.- Equality presets unified in
graph.ts:DEFAULT_EQUALITY,SKIP_EQUALITY, andDEEP_EQUALITYare all defined ingraph.tsalongsideSignalOptions. PreviouslyisEqual(the deep equality implementation) lived inlist.tsas a private function and was imported bystore.ts. Both files now importDEEP_EQUALITYfromgraph.ts; theCircularDependencyErrorimport inlist.tsis removed.
Deprecated
isObjectOfType(value, type): Marked@deprecated. Allocates two strings per call (Object.prototype.toString.call()plus a template literal). UseisSignalOfType(value, type)for signal type guards instead. The function remains exported for backward compatibility and will be removed in a future release.isEqual: Deprecated alias forDEEP_EQUALITY. Previously the private deep equality implementation inlist.ts, now re-exported fromindex.tsas a deprecated alias pointing toDEEP_EQUALITYingraph.ts. Replace all uses withDEEP_EQUALITY.
Fixed
createScopeeffect leak on throw: Previously, iffn()threw after creating child effects,disposewas never created or registered with the parent owner — child effects leaked and continued running indefinitely. Nowdisposeis created before thetryblock and registered withprevOwnerin thefinallyclause, so cleanup always executes regardless of whetherfn()throws.list.replace()spurious dependency edge: Previously, callingreplace()from inside an effect linked the item signal to the calling effect as a dependency (via the unguardedsignal.get()equality check). The effect re-ran — and permanently acquired the dependency — after eachreplace()call. Now the check usesuntrack(() => signal.get()), so no edge is created during the early-exit test.list.splice()signal corruption on same-key replace: Previously, splicing out an item and inserting a new item with the same content-based key left the key inkeysbut absent fromsignals—byKey()returnedundefinedsilently. Nowsplicedetects the key overlap and routes tochangeinstead of an add+remove pair.match()errcleanup silently dropped on thrown errors: Previously, the catch branch callederr([...])without capturing the return value — cleanup functions orPromise<MaybeCleanup>returned byerrwere silently discarded (memory leak in the error path). Nowout = err([...])captures the return value for cleanup registration, matching the try-branch behavior.
Full Changelog: v1.1.1...v1.2.0
Version 1.1.1
What's Changed
Added
- Single-signal overload for
match():match(signal, handlers)now accepts a bare signal (not wrapped in an array). Theokhandler receives the resolved value directly as(value: T), anderrreceives a singleErrorrather thanreadonly Error[]. The existing tuple form is unchanged. This eliminates the boilerplate of wrapping a single source in[source], destructuringvalues[0]inok, and unwrappingerrors[0]!inerr. SingleMatchHandlers<T>type: New exported type that describes the handler object for the single-signal overload. Counterpart to the existingMatchHandlers<T>for tuple usage.
Changed
- Async handler documentation: Added
@remarksto thematch()JSDoc and an expanded section inREADME.mdclarifying that asyncok/errhandlers are intended for external side effects only (logging, DOM writes, analytics). Any async work that needs to drive reactive state should use aTasknode, which receives anAbortSignaland is auto-cancelled on re-run. Documents the known limitation that rejected async handlers from stale (superseded) runs still callerr, since the library cannot cancel operations it did not initiate.
Fixed
Slot.set()now forwards through Slot-to-Slot chains: Previously, writing to a Slot whose backing signal was itself a Slot threwReadonlySignalErrorbecauseisMutableSignaldoes not includeSlot(by design — a Slot wrapping a read-only signal is not mutable).set()now recursively delegates to the next Slot in the chain, allowing the terminal backing signal to determine write permissions. Chains of arbitrary depth are resolved correctly.
Full Changelog: v1.0.2...v1.1.1
Version 1.0.2
What's Changed
Added
List.replace(key, value)— guaranteed item mutation: Updates the value of an existing item in place, propagating to all subscribers regardless of how they subscribed.byKey(key).set(value)only propagates throughitemSignal → listNodeedges, which are established lazily whenlist.get()is called; effects that subscribed vialist.keys(),list.length, or the iterator never trigger that path and receive no notification.replace()closes this gap by also walkingnode.sinksdirectly — the same structural propagation path used byadd(),remove(), andsort(). Signal identity is preserved: theState<T>returned bybyKey(key)is the same object before and after. No-op if the key does not exist or the value is reference-equal to the current value.
Full Changelog: v1.0.1...v1.0.2
Version 1.0.1
What's Changed
Added
cause-effectskill for consumer projects: New Claude Code skill with self-contained API knowledge inreferences/— no library source access required. Covers three workflows:use-api,debug, andanswer-question.README.mdUtilities section: Documents the previously undocumentedcreateSignal,createMutableSignal,createComputedfactories andisSignal,isMutableSignal,isComputedpredicates exported fromindex.ts.
Changed
cause-effect-devskill restructured: Refactored to progressive disclosure pattern with separateworkflows/andreferences/modules. Scoped explicitly to library development; external references toREQUIREMENTS.md,ARCHITECTURE.md, andsrc/are now clearly library-repo-only.- Documentation alignment: Corrected wrong graph node type for
StateinARCHITECTURE.md; added missingFLAG_RELINKandsrc/signal.tstocopilot-instructions.md; updatedREQUIREMENTS.mdstability section to reflect 1.0 release; completed and corrected JSDoc acrossSensor,Memo,Store,List,Collection, and utility types. No runtime behaviour changed. - TypeScript 6 compatibility: Added
erasableSyntaxOnlytotsconfig.json(requires TS ≥5.8); replaced@types/bunwithbun-typesdirectly and added"types": ["bun-types"]totsconfig.jsonto fix module resolution under TypeScript 6. - Package management cleanup: Added
typescripttodevDependencies(was only inpeerDependencies, causing stale version installs); updatedpeerDependenciesrange to>=5.8.0; removedpackage-lock.jsonand gitignored npm/yarn/pnpm lockfiles — Bun is required for development. - Zed editor configuration: Disabled ESLint language server for JS/TS/TSX in
.zed/settings.json— project uses Biome for linting.
Full Changelog: v1.0.0...v1.0.1