Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# Changelog

## [Unreleased]
## 1.5.2

### Added

- **`Cell<T>` / `MutableCell<T>` types, `isCell(x)` / `isMutableCell(x)` guards**: The 1.x bridge for [ADR-0018](adr/0018-shape-indexed-signal-types.md) §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.

## 1.5.1

Expand Down
1 change: 0 additions & 1 deletion NOTES.md

This file was deleted.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ const users = createList(
)
```

A `keyConfig` also controls what `.set()` treats as an item's identity. With a `keyConfig`, an item keeps its key only while its content stays equal to the previous item at that position. Changed content gets a new key instead. Without a `keyConfig`, array position is the identity. The key at each index stays the same regardless of content.

To rebuild a list from inside a reactive handler, use `.set()` or `.update()` rather than a remove-then-add loop, which throws `EffectConvergenceError`. See [Rebuilding a List from a reactive handler](RECIPES.md#3-rebuilding-a-list-from-a-reactive-handler).

> **Naming ahead of 2.0:** the mutable list type is also exported as `MutableList` — the name it keeps in 2.0, where `List` becomes the readonly base (today's `Collection`). `isMutableList()` is the matching guard. See [MIGRATION-2.0.md](MIGRATION-2.0.md).
Expand Down Expand Up @@ -270,6 +272,8 @@ createEffect(() => console.log('Items:', items.get()))

The watched callback activates lazily when an effect first reads the collection, and cleans up when no effect watches it. Options are `value` for initial items (default `[]`) and `keyConfig` for key generation.

Use a function `keyConfig` — not a string prefix, not the default — for an externally-driven collection. A `change` or `remove` entry matches an existing item by key. A function `keyConfig` derives that key from the item's content. It therefore matches an item that arrives as a new object, the normal case for parsed JSON. Without a function `keyConfig`, a `change` or `remove` entry matches only the exact object reference already tracked. Any other entry throws `UnresolvableKeyError`.

**Derived collections** transform Lists or other Collections through `.deriveCollection()`:

```js
Expand Down
2 changes: 2 additions & 0 deletions RECIPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ createEffect(() => match(task, {

The same rule applies to `Store`: prefer `.set()` over an `.remove()` + `.add()` sequence when a reactive handler owns the rebuild.

`.set()`'s identity guarantee depends on `keyConfig`. With a `keyConfig`, `forecast` keeps an item's key only while its content stays equal at that position. Changed content gets a new key instead of reusing the old one. Without a `keyConfig`, array position is the identity instead. A consumer that keys external resources — DOM nodes, caches — by list key needs a `keyConfig` for that guarantee to hold.

---

## 4. Async Side Effects in `match()`
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
Expand Down
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @name Cause & Effect
* @version 1.5.1
* @version 1.5.2
* @author Esther Brunner
*/

Expand All @@ -16,6 +16,7 @@ export {
PromiseValueError,
ReadonlySignalError,
RequiredOwnerError,
UnresolvableKeyError,
UnsetSignalValueError,
} from './src/errors'
export {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zeix/cause-effect",
"version": "1.5.1",
"version": "1.5.2",
"repository": {
"type": "git",
"url": "https://github.com/zeixcom/cause-effect"
Expand Down Expand Up @@ -28,7 +28,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.8",
"@types/bun": "^1.3.14",
"@zeix/cause-effect-stable": "npm:@zeix/cause-effect@1.4.1",
"@zeix/cause-effect-stable": "npm:@zeix/cause-effect@1.5.1",
"mitata": "^1.0.34",
"random": "^5.4.1",
"ts-morph": "^28.0.0",
Expand Down
20 changes: 20 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,25 @@ class PromiseValueError extends TypeError {
}
}

/**
* Error thrown when a `change`/`remove` entry passed to a Collection's `applyChanges` cannot
* be matched to an existing key.
*/
class UnresolvableKeyError extends Error {
/**
* Constructs a new UnresolvableKeyError.
*
* @param where - The location where the error occurred.
* @param value - The value that could not be resolved to a key.
*/
constructor(where: string, value: unknown) {
super(
`[${where}] Could not resolve a key for value ${valueString(value)} — a content-based keyConfig (item => key) is required to match change/remove entries against externally-sourced data, whose items are rarely reference-equal to what is already tracked`,
)
this.name = 'UnresolvableKeyError'
}
}

class DuplicateKeyError extends Error {
constructor(where: string, key: string, value?: unknown) {
super(
Expand Down Expand Up @@ -230,6 +249,7 @@ export {
PromiseValueError,
ReadonlySignalError,
RequiredOwnerError,
UnresolvableKeyError,
UnsetSignalValueError,
validateCallback,
validateReadValue,
Expand Down
54 changes: 45 additions & 9 deletions src/nodes/collection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
DuplicateKeyError,
UnresolvableKeyError,
UnsetSignalValueError,
validateCallback,
validateSignalValue,
Expand Down Expand Up @@ -88,7 +89,12 @@ type KeyedSource<T extends {}> = {
* @template T - The type of items in the derived sequence
*/
type DeriveListOptions<T extends {}> = {
/** Key generation strategy for an unkeyed source. See `KeyConfig`. Defaults to positional keys. */
/**
* Key generation strategy for an unkeyed source. See `KeyConfig`. Defaults to positional
* keys. In the external-push form (`watched`), a function `keyConfig` is required for a
* `change`/`remove` entry to match an item that is not the exact tracked object reference —
* see `ListChanges`.
*/
keyConfig?: KeyConfig<T>
/** Equality function for adapted per-item signals. Defaults to deep equality. */
itemEquals?: (a: T, b: T) => boolean
Expand Down Expand Up @@ -207,9 +213,17 @@ type Collection<T extends {}, S extends Signal<T> = Signal<T>> = DerivedList<
type ListChanges<T> = {
/** Items to add. Each item is assigned a new key via the configured `keyConfig`. */
add?: T[]
/** Items whose values have changed. Matched to existing entries by key. */
/**
* Items whose values have changed. Matched to existing entries by key. A non-content-based
* `keyConfig` matches only the exact tracked object reference — any other item throws
* `UnresolvableKeyError`.
*/
change?: T[]
/** Items to remove. Matched to existing entries by key. */
/**
* Items to remove. Matched to existing entries by key. A non-content-based `keyConfig`
* matches only the exact tracked object reference — any other item throws
* `UnresolvableKeyError`.
*/
remove?: T[]
}

Expand All @@ -236,7 +250,11 @@ type CollectionChanges<T> = ListChanges<T>
type CollectionOptions<T extends {}, S extends Signal<T> = Signal<T>> = {
/** Initial items. Defaults to `[]`. */
value?: T[]
/** Key generation strategy. See `KeyConfig`. Defaults to auto-increment. */
/**
* Key generation strategy. See `KeyConfig`. Defaults to auto-increment. A function
* `keyConfig` is required for a `change`/`remove` entry to match an item that is not the
* exact tracked object reference — see `ListChanges`.
*/
keyConfig?: KeyConfig<T>
/** Factory for per-item signals. Defaults to `createState`. */
createItem?: (value: T) => S
Expand Down Expand Up @@ -293,7 +311,9 @@ function keyedAdapter<T extends {}>(
source: Signal<T[]>,
options?: DeriveListOptions<T>,
): KeyedSource<T> {
const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig)
const [generateKey, contentBased, positional] = getKeyGenerator(
options?.keyConfig,
)
const itemEquals = options?.itemEquals ?? DEEP_EQUALITY
const signals = new Map<string, Memo<T>>()
const indices = new Map<string, number>()
Expand Down Expand Up @@ -330,6 +350,7 @@ function keyedAdapter<T extends {}>(
generateKey,
contentBased,
itemEquals,
positional,
)
prev = next
if (keysEqual(keys, diff.newKeys)) return
Expand Down Expand Up @@ -658,6 +679,11 @@ function createCollection<T extends {}, S extends Signal<T> = Signal<T>>(

const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig)

// With a content-based keyConfig, generateKey(item) can always compute a key from the
// item's content, so this never falls through to undefined. Without one, a change/remove
// entry can only be resolved by object identity — a real limitation for externally-sourced
// data (e.g. freshly-parsed JSON), which is rarely reference-equal across messages. See
// the throw in onChanges() below.
const resolveKey = (item: T): string | undefined =>
itemToKey.get(item) ?? (contentBased ? generateKey(item) : undefined)

Expand Down Expand Up @@ -736,11 +762,17 @@ function createCollection<T extends {}, S extends Signal<T> = Signal<T>>(
}
}

// Changes — only for State signals
// Changes — only for State signals. Keys are resolved for the whole batch
// before any mutation, so an unresolvable entry throws before anything commits
// — mirrors the add-loop's staging above.
if (change) {
const resolved: [string, T][] = []
for (const item of change) {
const key = resolveKey(item)
if (!key) continue
if (!key) throw new UnresolvableKeyError(TYPE_COLLECTION, item)
resolved.push([key, item])
}
for (const [key, item] of resolved) {
const signal = signals.get(key)
if (signal && isState(signal)) {
// Update reverse map: remove old reference, add new.
Expand All @@ -753,11 +785,15 @@ function createCollection<T extends {}, S extends Signal<T> = Signal<T>>(
}
}

// Removals
// Removals — same staging rationale as changes above.
if (remove) {
const resolved: [string, T][] = []
for (const item of remove) {
const key = resolveKey(item)
if (!key) continue
if (!key) throw new UnresolvableKeyError(TYPE_COLLECTION, item)
resolved.push([key, item])
}
for (const [key, item] of resolved) {
itemToKey.delete(item)
signals.delete(key)
const index = keys.indexOf(key)
Expand Down
Loading
Loading