Skip to content

Releases: zeixcom/cause-effect

Version 1.3.3

23 May 07:58
9ec95ef

Choose a tag to compare

What's Changed

Changed

  • List.buildValue() uses a push loop instead of map/filter: Eliminates the intermediate (T | undefined)[] allocation that map() produced before filter() could remove undefined entries. Now builds the result in a single pre-allocated pass.
  • List.sort() uses an imperative loop: Replaces keys.map(key => [key, signals.get(key)?.get()]).sort(...).map(([key]) => key) with a single entries build loop and a separate newOrder accumulation loop, removing two intermediate array allocations.
  • List.splice() and List.replace() use boolean flags for change detection: Replaces Object.keys(changes.change).length (iterates all keys to count) with an early-exit for...in loop that sets a flag on the first key found.
  • List.add() drops redundant keys.includes(key) guard: The preceding signals.has(key) check already throws DuplicateKeyError for duplicate keys; keys.includes(key) was unreachable dead code.
  • diffArrays split into diffPositional for non-content-based keys: Positional-key lists now take a dedicated fast path (diffPositional) that walks both arrays in a single O(n) pass with no Map or Set allocation. Content-based diffing retains the Map/Set approach for key-stability tracking.
  • syncKeys in deriveCollection reduces Set allocations: Previously constructed two Sets (new Set(keys) and new Set(nextKeys)). Now constructs only nextSet = new Set(nextKeys) for deletion detection and uses the existing signals Map directly (signals.has(key)) to decide whether a key needs to be added.
  • Store.buildValue() and Store[Symbol.iterator] iterate Map entries directly: buildValue replaces signals.forEach((signal, key) => ...) with for (const [key, signal] of signals). The iterator replaces Array.from(signals.keys()) and a secondary signals.get(key) lookup with a single for...of over signals entries, eliminating an intermediate array allocation.
  • Slot type assertion cleanup: Removed as any casts in isSignalOrDescriptor and createSlot.set; used return void delegated.set(next) in the Slot-to-Slot delegation path to avoid implicitly returning the inner call's result.

New Contributors


Full Changelog: v1.3.2...v1.3.3

Test Trusted Publishing Workflow

23 May 12:07
5e95829

Choose a tag to compare

Pre-release

What's Changed

Full Changelog: v1.3.3...v.1.3.4-beta.8

Version 1.3.2

28 Apr 07:52
84f454e

Choose a tag to compare

What's Changed

Fixed

  • Stale value and lost propagation after all consumers of a Slot or Memo disconnect and reconnect: Previously, when the last Effect unsubscribed from a Slot (or any intermediate MemoNode), unlink correctly cascaded into the MemoNode via trimSources — pruning the upstream State → MemoNode edge — but left flags as FLAG_CLEAN. On reconnect, refresh() saw FLAG_CLEAN and returned immediately without calling recomputeMemo: the source edge was never re-established, the node returned its stale cached value, and subsequent State.set() calls did not propagate at all (the source's sink list no longer contained the MemoNode). Now sinkNode.flags |= FLAG_DIRTY is set after the cascade trimSources in unlink (src/graph.ts). The next refresh() triggers recomputeMemo, which re-runs fn with activeSink = node, re-links the upstream edge via link(), 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

27 Apr 15:31
33d835d

Choose a tag to compare

What's Changed

Added

  • createScope now accepts an optional ScopeOptions second argument; { root: true } creates a root scope that is not registered on the current parent owner – the returned dispose is the sole teardown mechanism. Export new ScopeOptions type.

Changed

  • Improved type inference for createList and createCollection when providing a custom createItem factory (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

27 Apr 06:50
8f484d3

Choose a tag to compare

What's Changed

Added

  • SlotDescriptor support for bi-directional derivations: createSlot() and Slot#replace() now accept a duck-typed SlotDescriptor<T> object ({ get(): T, set?(next: T): void }) in addition to a Signal<T>. This allows establishing stable, native reactive edges for derived { get, set } pairs without the need for an intermediary Computed signal, which prevents edge corruption during cascading graph updates. If a descriptor omits the set function, the slot safely behaves as read-only.
  • Configurable itemEquals for List and Collection: Added an itemEquals option to both ListOptions and CollectionOptions. It defaults to DEEP_EQUALITY. This prevents spurious reactive propagation when spread-based updates (list.replace(key, { ...item.get(), field: newValue })) produce structurally identical items.
  • Configurable createItem factory for List: Added a createItem?: (value: T) => MutableSignal<T> option to ListOptions, bringing it to parity with CollectionOptions. This allows backing list items with custom mutable signals, such as createStore for granular, per-property reactivity within a list. List methods like at(), byKey(), and its iterator now correctly return MutableSignal<T> instead of strictly State<T>.

Full Changelog: v1.2.1...v1.3.0

Version 1.2.1

20 Apr 18:21
e0317aa

Choose a tag to compare

What's Changed

Fixed

  • match() stale handler not firing on re-fetches: Previously, stale only 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 became FLAG_CLEAN without running: propagate(taskNode) sent only FLAG_CHECK to downstream effects, so refresh(effectNode) called refresh(taskNode)recomputeTask(), which returned synchronously with no value change — the effect saw no FLAG_DIRTY and was cleaned without executing. Now recomputeTask() calls setState(node.pendingNode, true) immediately after the synchronous fn preamble. This propagates FLAG_DIRTY to subscribed effects mid-refresh, causing the source-check loop in refresh() to break and run the effect, which then routes to stale as expected.
  • task.isPending() is now reactive: Previously a plain boolean read (!!node.controller) that created no graph edges. Now backed by an internal pendingNode: StateNode<boolean> and subscribed via makeSubscribe — calling isPending() 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 call isPending() are unaffected. Promise .then/.catch handlers reset pendingNode to false inside a batch() alongside any value propagation to prevent double effect runs.

Full Changelog: v1.2.0...v1.2.1

Version 1.2.0

17 Apr 11:41
a4b4b0f

Choose a tag to compare

What's Changed

Added

  • stale handler for match(): Both MatchHandlers<T> and SingleMatchHandlers<T> now accept an optional stale?: () => MaybePromise<MaybeCleanup> branch. It fires when all signals have a retained value but at least one Task signal is currently executing (isPending() === true). Routing precedence is nil > err > stale > ok; omitting stale falls back to ok, showing the retained value unchanged while the task re-fetches. Any cleanup returned by stale is 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: nil maps to isLoading (no data yet); stale maps to isFetching with existing data.
  • isSignalOfType<T>(value, type) utility: New exported function that replaces isObjectOfType for signal type guards. Checks value != null && value[Symbol.toStringTag] === type directly — zero string allocations, O(1). All eight internal is*() guards (isState, isMemo, isTask, isSensor, isSlot, isStore, isList, isCollection) now use it.
  • DEEP_EQUALITY equality preset: New exported constant for deep structural comparison of plain objects and arrays. Uses Object.is as 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 the equals option to suppress propagation when a signal holding an object or array recomputes to a structurally identical value.
  • DEFAULT_EQUALITY exported from index.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 overriding SignalOptions.

Changed

  • isSignal uses a module-level Set with direct Symbol.toStringTag access: Previously allocated two strings per call via Object.prototype.toString.call(value).slice(8, -1) and scanned an inline array with Array.includes(). Now checks SIGNAL_TYPES.has(value[Symbol.toStringTag]) — one hash lookup, zero allocations, Set built once at module load.
  • isRecord uses a prototype check instead of Object.prototype.toString: Previously Object.prototype.toString.call(value) === '[object Object]', which returns true for class instances without a custom Symbol.toStringTag. Now checks Object.getPrototypeOf(value) === Object.prototype, which excludes class instances. Affects createSignal and createMutableSignal: a class instance with no Symbol.toStringTag previously resolved to a Store; now it falls through to createState. Class instances are not plain records, so this is the correct behavior.
  • isEqual / DEEP_EQUALITY cycle detection removed: Previously, the deep equality function in list.ts and store.ts allocated a WeakSet on every List.set() / Store.set() call, added both operands before recursing, and threw CircularDependencyError on a circular reference. The try/finally block cleaned up the WeakSet entries after each call. All of this is removed — the implementation is now plain recursion (deepEqual in graph.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, and DEEP_EQUALITY are all defined in graph.ts alongside SignalOptions. Previously isEqual (the deep equality implementation) lived in list.ts as a private function and was imported by store.ts. Both files now import DEEP_EQUALITY from graph.ts; the CircularDependencyError import in list.ts is removed.

Deprecated

  • isObjectOfType(value, type): Marked @deprecated. Allocates two strings per call (Object.prototype.toString.call() plus a template literal). Use isSignalOfType(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 for DEEP_EQUALITY. Previously the private deep equality implementation in list.ts, now re-exported from index.ts as a deprecated alias pointing to DEEP_EQUALITY in graph.ts. Replace all uses with DEEP_EQUALITY.

Fixed

  • createScope effect leak on throw: Previously, if fn() threw after creating child effects, dispose was never created or registered with the parent owner — child effects leaked and continued running indefinitely. Now dispose is created before the try block and registered with prevOwner in the finally clause, so cleanup always executes regardless of whether fn() throws.
  • list.replace() spurious dependency edge: Previously, calling replace() from inside an effect linked the item signal to the calling effect as a dependency (via the unguarded signal.get() equality check). The effect re-ran — and permanently acquired the dependency — after each replace() call. Now the check uses untrack(() => 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 in keys but absent from signalsbyKey() returned undefined silently. Now splice detects the key overlap and routes to change instead of an add+remove pair.
  • match() err cleanup silently dropped on thrown errors: Previously, the catch branch called err([...]) without capturing the return value — cleanup functions or Promise<MaybeCleanup> returned by err were silently discarded (memory leak in the error path). Now out = 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

11 Apr 11:08
388f29b

Choose a tag to compare

What's Changed

Added

  • Single-signal overload for match(): match(signal, handlers) now accepts a bare signal (not wrapped in an array). The ok handler receives the resolved value directly as (value: T), and err receives a single Error rather than readonly Error[]. The existing tuple form is unchanged. This eliminates the boilerplate of wrapping a single source in [source], destructuring values[0] in ok, and unwrapping errors[0]! in err.
  • SingleMatchHandlers<T> type: New exported type that describes the handler object for the single-signal overload. Counterpart to the existing MatchHandlers<T> for tuple usage.

Changed

  • Async handler documentation: Added @remarks to the match() JSDoc and an expanded section in README.md clarifying that async ok/err handlers are intended for external side effects only (logging, DOM writes, analytics). Any async work that needs to drive reactive state should use a Task node, which receives an AbortSignal and is auto-cancelled on re-run. Documents the known limitation that rejected async handlers from stale (superseded) runs still call err, 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 threw ReadonlySignalError because isMutableSignal does not include Slot (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

30 Mar 15:20
572485e

Choose a tag to compare

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 through itemSignal → listNode edges, which are established lazily when list.get() is called; effects that subscribed via list.keys(), list.length, or the iterator never trigger that path and receive no notification. replace() closes this gap by also walking node.sinks directly — the same structural propagation path used by add(), remove(), and sort(). Signal identity is preserved: the State<T> returned by byKey(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

30 Mar 10:36

Choose a tag to compare

What's Changed

Added

  • cause-effect skill for consumer projects: New Claude Code skill with self-contained API knowledge in references/ — no library source access required. Covers three workflows: use-api, debug, and answer-question.
  • README.md Utilities section: Documents the previously undocumented createSignal, createMutableSignal, createComputed factories and isSignal, isMutableSignal, isComputed predicates exported from index.ts.

Changed

  • cause-effect-dev skill restructured: Refactored to progressive disclosure pattern with separate workflows/ and references/ modules. Scoped explicitly to library development; external references to REQUIREMENTS.md, ARCHITECTURE.md, and src/ are now clearly library-repo-only.
  • Documentation alignment: Corrected wrong graph node type for State in ARCHITECTURE.md; added missing FLAG_RELINK and src/signal.ts to copilot-instructions.md; updated REQUIREMENTS.md stability section to reflect 1.0 release; completed and corrected JSDoc across Sensor, Memo, Store, List, Collection, and utility types. No runtime behaviour changed.
  • TypeScript 6 compatibility: Added erasableSyntaxOnly to tsconfig.json (requires TS ≥5.8); replaced @types/bun with bun-types directly and added "types": ["bun-types"] to tsconfig.json to fix module resolution under TypeScript 6.
  • Package management cleanup: Added typescript to devDependencies (was only in peerDependencies, causing stale version installs); updated peerDependencies range to >=5.8.0; removed package-lock.json and 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