Releases: zeixcom/cause-effect
Releases · zeixcom/cause-effect
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
Version 1.0.0
What's Changed
Changed
- Stricter TypeScript configuration: Enabled
noUncheckedIndexedAccess,exactOptionalPropertyTypes,useUnknownInCatchVariables,noUncheckedSideEffectImports, andnoFallthroughCasesInSwitchintsconfig.json. All internal array and indexed object accesses have been updated to satisfy these checks. Runtime behaviour is unchanged. stopon node types now typed asCleanup | undefined: Thestopproperty inSourceFields(and by extensionStateNode,MemoNode,TaskNode) is now declaredstop?: Cleanup | undefinedrather thanstop?: Cleanup. UnderexactOptionalPropertyTypes, this is required to allow clearing the property by assignment (= undefined) rather than deletion — preserving V8 hidden-class stability on hot-path nodes. Consumers readingstopfrom a node should already be handlingundefinedsince the property is optional, but TypeScript will now surface this requirement explicitly.guardon options types now requires explicit presence: UnderexactOptionalPropertyTypes, passing{ guard: undefined }toSignalOptions,ComputedOptions, orSensorOptionsis now a type error. Omit the property entirely to leave it unset.
Full Changelog: v0.18.5...v1.0.0
Version 0.18.5
What's Changed
Added
unown(fn)— escape hatch for DOM-owned component lifecycles: Runs a callback withactiveOwnerset tonull, preventing anycreateScopeorcreateEffectcalls inside from being registered as children of the current active owner. Use this inconnectedCallback(or any external lifecycle hook) when a component manages its own cleanup independently viadisconnectedCallbackrather than through the reactive ownership tree.
Fixed
- Scope disposal bug when
connectedCallbackfires inside a re-runnable effect: Previously, callingcreateScopeinside a reactive effect (e.g. a list sync effect) registered the scope'sdisposeon that effect's cleanup list. When the effect re-ran — for example, because aMutationObserverfired — it calledrunCleanup, disposing all child scopes including those belonging to already-connected custom elements. This silently removed event listeners and reactive subscriptions from components that were still live in the DOM. Wrapping theconnectedCallbackbody inunown(() => createScope(...))detaches the scope from the effect's ownership, so effect re-runs no longer dispose it.
Full Changelog: v0.18.4...v0.18.5
Version 0.18.4
What's Changed
Fixed
- Watched
invalidate()now respectsequalsat every graph level: Previously, callinginvalidate()from a Memo or Taskwatchedcallback propagatedFLAG_DIRTYdirectly to effect sinks, causing unconditional re-runs even when the recomputed value was unchanged. Nowinvalidate()delegates topropagate(node), which marks the node itselfFLAG_DIRTYand propagatesFLAG_CHECKto downstream sinks. During flush, effects verify their sources viarefresh()— if the memo'sequalsfunction determines the value is unchanged, the effect is cleaned without running. This eliminates unnecessary effect executions for watched memos with custom equality or stable return values.
Changed
propagate()supportsFLAG_CHECKfor effect nodes: The effect branch ofpropagate()now respects thenewFlagparameter instead of unconditionally settingFLAG_DIRTY. Effects are enqueued only on first notification; subsequent propagations escalate the flag (e.g.,CHECK→DIRTY) without re-enqueuing.flush()processesFLAG_CHECKeffects: The flush loop now callsrefresh()on effects with eitherFLAG_DIRTYorFLAG_CHECK, enabling the check-sources-first path for effects.- Task
invalidate()aborts eagerly: Task watched callbacks now abort in-flight computations immediately duringpropagate()rather than deferring torecomputeTask(), consistent with the normal dependency-change path.
Full Changelog: v0.18.3...v0.18.4
Version 0.18.2
What's Changed
Fixed
watchedpropagation throughderiveCollection()chains: When an effect reads a derived collection, thewatchedcallback on the source List, Store, or Collection now activates correctly — even through multiple levels of.deriveCollection()chaining. Previously,deriveCollectiondid not propagate sink subscriptions back to the source'swatchedlifecycle.- Stable
watchedlifecycle during mutations: Adding, removing, or sorting items on a List (or Store/Collection) consumed throughderiveCollection()no longer tears down and restarts thewatchedcallback. The watcher remains active as long as at least one downstream effect is subscribed. - Cleanup cascade on disposal: When the last effect unsubscribes from a derived collection chain, cleanup now propagates upstream through all intermediate nodes to the source, correctly invoking the
watchedcleanup function.
Changed
FLAG_RELINKreplaces source-nulling in composite signals: Store, List, Collection, and deriveCollection no longer null outnode.sources/node.sourcesTailon structural mutations. Instead, a newFLAG_RELINKbitmap flag triggers a trackedrefresh()on the next.get()call, re-establishing edges cleanly vialink()/trimSources()without orphaning them.- Cascading
trimSources()inunlink(): When a MemoNode loses all sinks, its own sources are now trimmed recursively, ensuring upstreamwatchedcleanup propagates correctly through intermediate nodes. - Three-path
ensureFresh()inderiveCollection: The internal freshness check now distinguishes between fast path (has sources, clean), first subscriber (has sinks but no sources yet), and no subscriber (untracked build). This prevents prematurewatchedactivation during initialization.
Full Changelog: v0.18.1...v0.18.2
Version 0.18.1
What's Changed
Added
- Memo
watched(invalidate)option:createMemo(fn, { watched })accepts a lazy lifecycle callback that receives aninvalidatefunction. Callinginvalidate()marks the memo dirty and triggers re-evaluation. The callback is invoked on first sink attachment and cleaned up when the last sink detaches. This enables patterns like DOM observation where a memo re-derives its value in response to external events (e.g., MutationObserver) without needing a separate Sensor. - Task
watched(invalidate)option: Same pattern as Memo. Callinginvalidate()aborts any in-flight computation and triggers re-execution. CollectionChanges<T>type: New typed interface for collection mutations withadd?: T[],change?: T[],remove?: T[]arrays. Replaces the untypedDiffResultrecords previously used byCollectionCallback.SensorOptions<T>type: Dedicated options type forcreateSensor, extendingSignalOptions<T>with optionalvalue.CollectionChangesexport from public API (index.ts).SensorOptionsexport from public API (index.ts).
Changed
createSensorparameter renamed:start→watchedfor consistency with Store/List lifecycle terminology.createSensoroptions type:ComputedOptions<T>→SensorOptions<T>. This decouples Sensor options fromComputedOptions, which now carries thewatched(invalidate)field for Memo/Task.createCollectionparameter renamed:start→watchedfor consistency.CollectionCallbackis now generic:CollectionCallback→CollectionCallback<T>. TheapplyChangesparameter acceptsCollectionChanges<T>instead ofDiffResult.CollectionOptions.createItemsignature:(key: string, value: T) => Signal<T>→(value: T) => Signal<T>. Key generation is now handled internally.KeyConfig<T>return type relaxed: Key functions may now returnstring | undefined. Returningundefinedfalls back to synthetic key generation.
Removed
DiffResultremoved from public API: No longer re-exported fromindex.ts. The type remains available fromsrc/nodes/list.tsfor internal use but is superseded byCollectionChanges<T>for collection mutations.
Full Changelog: v0.18.0...v0.18.1