Skip to content

Version 1.5.2 - #85

Merged
estherbrunner merged 11 commits into
mainfrom
next
Aug 19, 2026
Merged

Version 1.5.2#85
estherbrunner merged 11 commits into
mainfrom
next

Conversation

@estherbrunner

Copy link
Copy Markdown
Member

Added

  • Cell<T> / MutableCell<T> types, isCell(x) / isMutableCell(x) guards: The 1.x bridge for ADR-0018 §8's shape-indexed Cell type. Cell<T> = State<T> | Memo<T> | Task<T> | Sensor<T> — a genuine structural narrowing of Signal<T>, not just a rename: each origin already carries a distinct Symbol.toStringTag literal ('State' | 'Memo' | 'Task' | 'Sensor'), so the union excludes List<T> / Store<T> / Collection<T> at the type level with no runtime tag change. MutableCell<T> = State<T>, an alias matching createCell's existing return value. isCell/isMutableCell check Symbol.toStringTag membership; isSignal/isMutableSignal keep their unchanged umbrella meaning. deriveCell's overloads now return Cell<T> instead of the wider Signal<T>, and createCell now returns MutableCell<T> instead of State<T> — both widening-safe, since every Cell/MutableCell value already satisfies Signal/MutableSignal structurally, so no existing caller's code breaks.
  • UnresolvableKeyError: New error class, exported from the package root. Thrown by createCollection's (and external-push deriveList's) applyChanges({ change, remove }) when an entry cannot be matched to an existing key. See the Fixed entry below.

Fixed

  • deriveCell(input, options?) mis-inferred a zero/single-arg async callback's return type as Promise<T> (src/nodes/cell.ts, formerly src/signal.ts): The overloads declared the sync MemoCallback<T> form before the async TaskCallback<T> form. A zero/single-arg async () => T callback is structurally assignable to MemoCallback<T> too (fewer parameters is always fine), and TypeScript's overload resolution picks the first structural match — so T unified to Promise<...> instead of the resolved value type. For example, deriveCell(async () => new Map<string, number>()) inferred Signal<Promise<Map<string, number>>> instead of Signal<Map<string, number>>. The deprecated createComputed already ordered TaskCallback before MemoCallback to avoid exactly this; deriveCell's overloads are now reordered to match. Type-inference-only fix — no runtime behavior change.
  • Four per-item derivation overload pairs had the same sync-before-async ordering bug (src/nodes/list.ts, src/nodes/collection.ts): MutableList.deriveCollection(), DerivedList.deriveCollection(), the deprecated free function deriveCollection(), and deriveList()'s per-item overloads (the current v2.0-facing API) all declared a single-arg sync callback (sourceValue: T) => R before the two-arg async callback (sourceValue: T, abort: AbortSignal) => Promise<R>. Since R is unconstrained, a single-arg async callback that ignores abort — a common shape — structurally matched the sync overload first, unifying R to Promise<X> instead of X. For example, deriveList(source, async (item) => ({ value: item.id })) inferred DerivedList<Promise<{ value: string }>> instead of DerivedList<{ value: string }>. Each pair is now reordered so the async overload comes first, matching the deriveCell fix above. deriveList's whole-array overloads and deriveStore were not affected — their sync-form return type is a concrete T[]/UnknownRecord shape, which already blocks the bad unification. Type-inference-only fix — no runtime behavior change.
  • List.set() / deriveList reused a keyConfig'd item's key across a content change instead of retiring it (src/nodes/list.ts, src/nodes/collection.ts): Previously, diffPositional() — used by MutableList.set() and, through keyedAdapter's ensureKeys(), by deriveList deriving from a plain Signal<T[]> — reused prevKeys[i] at any shared index where itemEquals failed, regardless of whether a keyConfig was configured, emitting a change under the old key instead of a remove+add. A key could therefore end up silently pointing at unrelated content with no structural event ever firing, undermining a consumer that keys external resources — DOM nodes, caches — by list key. Now, with a keyConfig (string prefix or function), a content mismatch at a shared index retires the old key and mints a fresh one via generateKey(), matching splice()'s existing semantics. The no-keyConfig case is unchanged by design: array position stays the identity, so the key at each index stays the same regardless of content.
  • createCollection's applyChanges({ change, remove }) silently dropped an entry it could not resolve to a key (src/nodes/collection.ts): Previously, without a content-based (function) keyConfig, resolveKey() could only match a change/remove entry by object reference — unworkable for externally-sourced data such as parsed JSON, which is rarely reference-equal across messages — so an unresolvable entry silently no-op'd (if (!key) continue), with no error and no diagnostic. A content-based keyConfig was and remains unaffected: a genuinely nonexistent key still no-ops gracefully, the same as List.remove() on a nonexistent key. Now an unresolvable entry throws UnresolvableKeyError instead. onChanges()'s change and remove loops resolve keys for the whole batch before mutating anything, mirroring the existing add-loop staging, so a batch containing an unresolvable entry throws before any of it is applied.

estherbrunner and others added 11 commits August 19, 2026 06:06
…nd-cell-guards

Bugfix/derive cell overload fix and cell guards
…e/remove

List.set()/deriveList reused a keyConfig'd item's key across a content
change at a shared index instead of retiring it, so a key could silently
end up pointing at unrelated content. createCollection's applyChanges()
had a related but separate issue: change/remove entries it could not
resolve to a key without a content-based keyConfig were silently
dropped instead of surfacing an error.

diffPositional() now mints a fresh key on content mismatch when a
keyConfig is given (matching splice()'s existing semantics), while the
no-keyConfig positional case is unchanged. createCollection now throws
UnresolvableKeyError for a change/remove entry it cannot resolve,
instead of silently no-oping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pin was stuck at 1.4.1 while latest published is 1.5.1, adding three
minor releases of accumulated drift to every measured ratio. The
list-key-reuse fix on this branch only touches list.ts/collection.ts
and cannot affect signalCreation, confirming the CI failure was drift,
not a real regression.
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​zeix/​cause-effect@​1.4.1 ⏵ 1.5.179 +1100100 +198 +1100

View full report

@estherbrunner estherbrunner self-assigned this Aug 19, 2026
@estherbrunner estherbrunner added bug Something isn't working documentation Improvements or additions to documentation labels Aug 19, 2026
@estherbrunner
estherbrunner merged commit 76ae5f8 into main Aug 19, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant