Skip to content

Commit 8538112

Browse files
Merge pull request #24 from zeixcom/feature/collection-changes
Improve Consistency for Lazy Lifecycle (watched) across API
2 parents 3a557e4 + 186ca78 commit 8538112

35 files changed

+1680
-829
lines changed

.ai-context.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ Scope — owner-only (createScope)
2828
### Signal Types
2929
- **State** (`createState`): Mutable signals for primitive values and objects
3030
- **Sensor** (`createSensor`): Read-only signals for external input streams with automatic state updates. Use `SKIP_EQUALITY` for mutable object observation.
31-
- **Memo** (`createMemo`): Synchronous derived computations with memoization and reducer capabilities
32-
- **Task** (`createTask`): Async derived computations with automatic abort/cancellation
31+
- **Memo** (`createMemo`): Synchronous derived computations with memoization, reducer capabilities, and optional `watched(invalidate)` for external invalidation
32+
- **Task** (`createTask`): Async derived computations with automatic abort/cancellation and optional `watched(invalidate)` for external invalidation
3333
- **Store** (`createStore`): Mutable object signals with individually reactive properties via Proxy
3434
- **List** (`createList`): Mutable array signals with stable keys and reactive items
3535
- **Collection** (`createCollection`): Reactive collections — either externally-driven with watched lifecycle, or derived from List/Collection with item-level memoization
@@ -223,7 +223,7 @@ const processed = items.deriveCollection(item => item.toUpperCase())
223223

224224
## Resource Management
225225

226-
**Sensor and Collection** use a start callback that returns a Cleanup function:
226+
**Sensor and Collection** use a watched callback that returns a Cleanup function:
227227
```typescript
228228
const sensor = createSensor<T>((set) => {
229229
// setup input tracking, call set(value) to update
@@ -236,6 +236,17 @@ const feed = createCollection<T>((applyChanges) => {
236236
}, { keyConfig: item => item.id })
237237
```
238238

239+
**Memo and Task** use an optional `watched` callback in options that receives an `invalidate` function:
240+
```typescript
241+
const derived = createMemo(() => element.get().textContent ?? '', {
242+
watched: (invalidate) => {
243+
const obs = new MutationObserver(() => invalidate())
244+
obs.observe(element.get(), { childList: true })
245+
return () => obs.disconnect()
246+
}
247+
})
248+
```
249+
239250
**Store and List** use an optional `watched` callback in options:
240251
```typescript
241252
const store = createStore(initialValue, {

.github/copilot-instructions.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
1515
### Signal Types (all in `src/nodes/`)
1616
- **State** (`createState`): Mutable signals for values (`get`, `set`, `update`)
1717
- **Sensor** (`createSensor`): Read-only signals for external input with automatic state updates. Use `SKIP_EQUALITY` for mutable object observation.
18-
- **Memo** (`createMemo`): Synchronous derived computations with memoization and reducer capabilities
19-
- **Task** (`createTask`): Async derived computations with automatic AbortController cancellation
18+
- **Memo** (`createMemo`): Synchronous derived computations with memoization, reducer capabilities, and optional `watched(invalidate)` for external invalidation
19+
- **Task** (`createTask`): Async derived computations with automatic AbortController cancellation and optional `watched(invalidate)` for external invalidation
2020
- **Store** (`createStore`): Proxy-based reactive objects with per-property State/Store signals
2121
- **List** (`createList`): Reactive arrays with stable keys and per-item State signals
2222
- **Collection** (`createCollection`): Reactive collections — either externally-driven with watched lifecycle, or derived from List/Collection with item-level memoization
@@ -72,7 +72,8 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
7272
- All signals have `.get()` for value access
7373
- Mutable signals (State) have `.set(value)` and `.update(fn)`
7474
- Store properties are automatically reactive signals via Proxy
75-
- Sensor/Collection use a start callback returning Cleanup (lazy activation)
75+
- Sensor/Collection use a watched callback returning Cleanup (lazy activation)
76+
- Memo/Task use optional `watched(invalidate)` callback in options for external invalidation
7677
- Store/List use optional `watched` callback in options returning Cleanup
7778
- Effects return a dispose function (Cleanup)
7879

@@ -191,18 +192,27 @@ const count = createState(0, {
191192
## Resource Management
192193

193194
```typescript
194-
// Sensor: lazy external input tracking
195+
// Sensor: lazy external input tracking (watched callback with set)
195196
const sensor = createSensor<T>((set) => {
196197
// setup — call set(value) to update
197198
return () => { /* cleanup — called when last effect stops watching */ }
198199
})
199200

200-
// Collection: lazy external data source
201+
// Collection: lazy external data source (watched callback with applyChanges)
201202
const feed = createCollection<T>((applyChanges) => {
202203
// setup — call applyChanges(diffResult) on changes
203204
return () => { /* cleanup */ }
204205
}, { keyConfig: item => item.id })
205206

207+
// Memo/Task: optional watched callback with invalidate
208+
const derived = createMemo(() => element.get().textContent ?? '', {
209+
watched: (invalidate) => {
210+
const obs = new MutationObserver(() => invalidate())
211+
obs.observe(element.get(), { childList: true })
212+
return () => obs.disconnect()
213+
}
214+
})
215+
206216
// Store/List: optional watched callback
207217
const store = createStore(initialValue, {
208218
watched: () => {

ARCHITECTURE.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,14 @@ A mutable value container. The simplest signal type — `get()` links and return
178178

179179
**Graph node**: `StateNode<T>` (source only)
180180

181-
A read-only signal that tracks external input. The `start` callback receives a `set` function that updates the node's value via `setState()`. Sensors cover two patterns:
181+
A read-only signal that tracks external input. The `watched` callback receives a `set` function that updates the node's value via `setState()`. Sensors cover two patterns:
182182

183-
1. **Tracking external values** (default): Receives replacement values from events (mouse position, resize events). Equality checking (`Object.is` by default) prevents unnecessary propagation.
183+
1. **Tracking external values** (default): Receives replacement values from events (mouse position, resize events). Equality checking (`===` by default) prevents unnecessary propagation.
184184
2. **Observing mutable objects** (with `SKIP_EQUALITY`): Holds a stable reference to a mutable object (DOM element, Map, Set). `set(sameRef)` with `equals: SKIP_EQUALITY` always propagates, notifying consumers that the object's internals have changed.
185185

186-
The value starts undefined unless `options.value` is provided. Reading a sensor before its `start` callback has called `set()` (and without `options.value`) throws `UnsetSignalValueError`.
186+
The value starts undefined unless `options.value` is provided. Reading a sensor before its `watched` callback has called `set()` (and without `options.value`) throws `UnsetSignalValueError`.
187187

188-
**Lazy lifecycle**: The `start` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`).
188+
**Lazy lifecycle**: The `watched` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`).
189189

190190
### Memo (`src/nodes/memo.ts`)
191191

@@ -199,6 +199,8 @@ The `error` field preserves thrown errors: if `fn` throws, the error is stored a
199199

200200
**Reducer pattern**: The `prev` parameter enables state accumulation across recomputations without writable state.
201201

202+
**Watched lifecycle**: An optional `watched` callback in options provides lazy external invalidation. The callback receives an `invalidate` function and is invoked on first sink attachment. Calling `invalidate()` marks the node `FLAG_DIRTY`, propagates to sinks, and flushes — triggering re-evaluation of the memo's `fn` without changing any tracked dependency. The returned cleanup is stored as `node.stop` and called when the last sink detaches. This enables patterns like DOM observation (MutationObserver) where the memo re-derives its value in response to external events.
203+
202204
### Task (`src/nodes/task.ts`)
203205

204206
**Graph node**: `TaskNode<T>` (source + sink)
@@ -209,6 +211,8 @@ During dependency tracking, only the synchronous preamble of `fn` is tracked (be
209211

210212
`isPending()` returns `true` while a computation is in flight. `abort()` cancels the current computation manually. Errors are preserved like Memo, but old values are retained on errors (the last successful result remains accessible).
211213

214+
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()` marks the node dirty and triggers re-execution, which aborts any in-flight computation via the existing `AbortController` mechanism before starting a new one.
215+
212216
### Effect (`src/nodes/effect.ts`)
213217

214218
**Graph node**: `EffectNode` (sink only)
@@ -247,28 +251,26 @@ A reactive array with stable keys and per-item reactivity. Each item becomes a `
247251

248252
Collection implements two creation patterns that share the same `Collection<T>` interface:
249253

250-
#### `createCollection(start, options?)` — externally driven
254+
#### `createCollection(watched, options?)` — externally driven
251255

252256
**Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
253257

254-
An externally-driven reactive collection with a watched lifecycle, mirroring `createSensor(start, options?)`. The `start` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations. Initial items are provided via `options.value` (default `[]`).
258+
An externally-driven reactive collection with a watched lifecycle, mirroring `createSensor(watched, options?)`. The `watched` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations. Initial items are provided via `options.value` (default `[]`).
255259

256-
**Lazy lifecycle**: Like Sensor, the `start` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`). The `startWatching()` guard ensures `start` fires before `link()` so synchronous mutations inside `start` update `node.value` before the activating effect reads it.
260+
**Lazy lifecycle**: Like Sensor, the `watched` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`). The `startWatching()` guard ensures `watched` fires before `link()` so synchronous mutations inside `watched` update `node.value` before the activating effect reads it.
257261

258262
**External mutation via `applyChanges`**: Additions create new item signals (via configurable `createItem` factory, default `createState`). Changes update existing `State` signals. Removals delete signals and keys. Structural changes null out `node.sources` to force edge re-establishment. The node uses `equals: () => false` since structural changes are managed externally rather than detected by diffing.
259263

260264
**Two-path access**: Same pattern as Store/List — first `get()` uses `refresh()` to establish edges from child signals to the collection node; subsequent reads use `untrack(buildValue)` to avoid re-linking.
261265

262266
#### `deriveCollection(source, callback)` — internally derived
263267

264-
**Graph node**: `MemoNode<string[]>` (source + sink, tracks keys not values)
268+
**Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
265269

266270
An internal factory (not exported from the public API) that creates a read-only derived transformation of a List or another Collection. Exposed to users via the `.deriveCollection(callback)` method on List and Collection. Each source item is individually memoized: sync callbacks create `Memo` signals, async callbacks create `Task` signals.
267271

268-
**Two-level reactivity**: The derived collection's `MemoNode` tracks structural changes only — its `fn` (`syncKeys`) reads `source.keys()` to detect additions and removals. Value-level changes flow through the individual per-item `Memo`/`Task` signals, which independently track their source item.
269-
270-
**Key differences from Store/List**: The `MemoNode.value` is a `string[]` (the keys array), not the collection's actual values. The `equals` function is a shallow string array comparison (`keysEqual`). The node starts `FLAG_DIRTY` (unlike Store/List which start clean after initialization) to ensure the first `refresh()` establishes the edge from source to collection.
272+
**Consistent with Store/List/createCollection**: The `MemoNode.value` is a `T[]` (cached computed values), and keys are tracked in a separate local `string[]` variable. The `equals` function uses shallow reference equality on array elements to prevent unnecessary downstream propagation when re-evaluation produces the same item references. The node starts `FLAG_DIRTY` to ensure the first `refresh()` establishes edges.
271273

272-
**No `invalidateEdges`**: Unlike Store/List, the derived collection never needs to re-establish its source edge because it has exactly one source (the parent List or Collection) that never changes identity. Structural changes (adding/removing per-item signals) happen inside `syncKeys` without affecting the source edge.
274+
**Two-path access with structural fallback**: Same pattern as Store/List — first `get()` uses `refresh()` to establish edges; subsequent reads use `untrack(buildValue)`. A `syncKeys()` step inside `buildValue` syncs the signals map with `source.keys()`. If keys changed, `syncKeys` nulls `node.sources` to force edge re-establishment via `refresh()`, ensuring new child signals are properly linked.
273275

274-
**Chaining**: `.deriveCollection()` creates a new derived collection from an existing one, forming a pipeline. Each level in the chain has its own `MemoNode` for structural tracking and its own set of per-item derived signals.
276+
**Chaining**: `.deriveCollection()` creates a new derived collection from an existing one, forming a pipeline. Each level in the chain has its own `MemoNode` for value caching and its own set of per-item derived signals.

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Changelog
2+
3+
## 0.18.1
4+
5+
### Added
6+
7+
- **Memo `watched(invalidate)` option**: `createMemo(fn, { watched })` accepts a lazy lifecycle callback that receives an `invalidate` function. Calling `invalidate()` 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.
8+
- **Task `watched(invalidate)` option**: Same pattern as Memo. Calling `invalidate()` aborts any in-flight computation and triggers re-execution.
9+
- **`CollectionChanges<T>` type**: New typed interface for collection mutations with `add?: T[]`, `change?: T[]`, `remove?: T[]` arrays. Replaces the untyped `DiffResult` records previously used by `CollectionCallback`.
10+
- **`SensorOptions<T>` type**: Dedicated options type for `createSensor`, extending `SignalOptions<T>` with optional `value`.
11+
- **`CollectionChanges` export** from public API (`index.ts`).
12+
- **`SensorOptions` export** from public API (`index.ts`).
13+
14+
### Changed
15+
16+
- **`createSensor` parameter renamed**: `start``watched` for consistency with Store/List lifecycle terminology.
17+
- **`createSensor` options type**: `ComputedOptions<T>``SensorOptions<T>`. This decouples Sensor options from `ComputedOptions`, which now carries the `watched(invalidate)` field for Memo/Task.
18+
- **`createCollection` parameter renamed**: `start``watched` for consistency.
19+
- **`CollectionCallback` is now generic**: `CollectionCallback``CollectionCallback<T>`. The `applyChanges` parameter accepts `CollectionChanges<T>` instead of `DiffResult`.
20+
- **`CollectionOptions.createItem` signature**: `(key: string, value: T) => Signal<T>``(value: T) => Signal<T>`. Key generation is now handled internally.
21+
- **`KeyConfig<T>` return type relaxed**: Key functions may now return `string | undefined`. Returning `undefined` falls back to synthetic key generation.
22+
23+
### Removed
24+
25+
- **`DiffResult` removed from public API**: No longer re-exported from `index.ts`. The type remains available from `src/nodes/list.ts` for internal use but is superseded by `CollectionChanges<T>` for collection mutations.
26+
27+
## 0.18.0
28+
29+
Baseline release. Factory function API (`createState`, `createMemo`, `createTask`, `createEffect`, `createStore`, `createList`, `createCollection`, `createSensor`) with linked-list graph engine.

CLAUDE.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
8686
### Collection Architecture
8787
8888
**Collections** (`createCollection`): Externally-driven collections with watched lifecycle
89-
- Created via `createCollection(start, options?)` — mirrors `createSensor(start, options?)`
90-
- The `start` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations
89+
- Created via `createCollection(watched, options?)` — mirrors `createSensor(watched, options?)`
90+
- The `watched` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations
9191
- `options.value` provides initial items (default `[]`), `options.keyConfig` configures key generation
92-
- Lazy activation: `start` callback invoked on first effect access, cleanup when unwatched
92+
- Lazy activation: `watched` callback invoked on first effect access, cleanup when unwatched
9393
9494
**Derived Collections** (`deriveCollection`): Transformed from Lists or other Collections
9595
- Created via `list.deriveCollection(callback)` or `collection.deriveCollection(callback)`
@@ -124,7 +124,7 @@ Computed signals implement smart memoization:
124124
125125
## Resource Management with Watch Callbacks
126126
127-
Sensor and Collection signals use a **start callback** pattern for lazy resource management. Resources are allocated only when a signal is first accessed by an effect and automatically cleaned up when no effects are watching:
127+
Sensor, Collection, Memo (with `watched` option), and Task (with `watched` option) use a **watched callback** pattern for lazy resource management. Resources are allocated only when a signal is first accessed by an effect and automatically cleaned up when no effects are watching:
128128
129129
```typescript
130130
// Sensor: track external input with state updates
@@ -164,9 +164,9 @@ const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
164164
```
165165
166166
**Watch Lifecycle**:
167-
1. First effect accesses signal → start/watched callback executed
167+
1. First effect accesses signal → watched callback executed
168168
2. Last effect stops watching → returned cleanup function executed
169-
3. New effect accesses signal → start/watched callback executed again
169+
3. New effect accesses signal → watched callback executed again
170170
171171
This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
172172
@@ -230,7 +230,7 @@ const firstTodo = todoList.byKey('task1') // Access by stable key
230230
231231
**Collection (`createCollection`)**:
232232
- Externally-driven keyed collections (WebSocket streams, SSE, external data feeds)
233-
- Mirrors `createSensor(start, options?)`start callback pattern with watched lifecycle
233+
- Mirrors `createSensor(watched, options?)`watched callback pattern with lazy lifecycle
234234
- Same `Collection` interface — `.get()`, `.byKey()`, `.keys()`, `.deriveCollection()`
235235
236236
```typescript
@@ -263,6 +263,7 @@ const processed = todoList
263263
**Memo (`createMemo`)**:
264264
- Synchronous derived computations with memoization
265265
- Reducer pattern with previous value access
266+
- Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
266267
267268
```typescript
268269
const doubled = createMemo(() => count.get() * 2)
@@ -283,6 +284,7 @@ const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
283284
284285
**Task (`createTask`)**:
285286
- Async computations with automatic cancellation
287+
- Optional `watched(invalidate)` callback for lazy external invalidation
286288
287289
```typescript
288290
const userData = createTask(async (prev, abort) => {

0 commit comments

Comments
 (0)