@zeix/cause-effect 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.ai-context.md CHANGED
@@ -28,8 +28,8 @@ Scope — owner-only (createScope)
28
28
  ### Signal Types
29
29
  - **State** (`createState`): Mutable signals for primitive values and objects
30
30
  - **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
33
33
  - **Store** (`createStore`): Mutable object signals with individually reactive properties via Proxy
34
34
  - **List** (`createList`): Mutable array signals with stable keys and reactive items
35
35
  - **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())
223
223
 
224
224
  ## Resource Management
225
225
 
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:
227
227
  ```typescript
228
228
  const sensor = createSensor<T>((set) => {
229
229
  // setup input tracking, call set(value) to update
@@ -236,6 +236,17 @@ const feed = createCollection<T>((applyChanges) => {
236
236
  }, { keyConfig: item => item.id })
237
237
  ```
238
238
 
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
+
239
250
  **Store and List** use an optional `watched` callback in options:
240
251
  ```typescript
241
252
  const store = createStore(initialValue, {
@@ -15,8 +15,8 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
15
15
  ### Signal Types (all in `src/nodes/`)
16
16
  - **State** (`createState`): Mutable signals for values (`get`, `set`, `update`)
17
17
  - **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
20
20
  - **Store** (`createStore`): Proxy-based reactive objects with per-property State/Store signals
21
21
  - **List** (`createList`): Reactive arrays with stable keys and per-item State signals
22
22
  - **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
72
72
  - All signals have `.get()` for value access
73
73
  - Mutable signals (State) have `.set(value)` and `.update(fn)`
74
74
  - 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
76
77
  - Store/List use optional `watched` callback in options returning Cleanup
77
78
  - Effects return a dispose function (Cleanup)
78
79
 
@@ -191,18 +192,27 @@ const count = createState(0, {
191
192
  ## Resource Management
192
193
 
193
194
  ```typescript
194
- // Sensor: lazy external input tracking
195
+ // Sensor: lazy external input tracking (watched callback with set)
195
196
  const sensor = createSensor<T>((set) => {
196
197
  // setup — call set(value) to update
197
198
  return () => { /* cleanup — called when last effect stops watching */ }
198
199
  })
199
200
 
200
- // Collection: lazy external data source
201
+ // Collection: lazy external data source (watched callback with applyChanges)
201
202
  const feed = createCollection<T>((applyChanges) => {
202
203
  // setup — call applyChanges(diffResult) on changes
203
204
  return () => { /* cleanup */ }
204
205
  }, { keyConfig: item => item.id })
205
206
 
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
+
206
216
  // Store/List: optional watched callback
207
217
  const store = createStore(initialValue, {
208
218
  watched: () => {
package/ARCHITECTURE.md CHANGED
@@ -178,14 +178,14 @@ A mutable value container. The simplest signal type — `get()` links and return
178
178
 
179
179
  **Graph node**: `StateNode<T>` (source only)
180
180
 
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:
182
182
 
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.
184
184
  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.
185
185
 
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`.
187
187
 
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()`).
189
189
 
190
190
  ### Memo (`src/nodes/memo.ts`)
191
191
 
@@ -199,6 +199,8 @@ The `error` field preserves thrown errors: if `fn` throws, the error is stored a
199
199
 
200
200
  **Reducer pattern**: The `prev` parameter enables state accumulation across recomputations without writable state.
201
201
 
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
+
202
204
  ### Task (`src/nodes/task.ts`)
203
205
 
204
206
  **Graph node**: `TaskNode<T>` (source + sink)
@@ -209,6 +211,8 @@ During dependency tracking, only the synchronous preamble of `fn` is tracked (be
209
211
 
210
212
  `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).
211
213
 
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
+
212
216
  ### Effect (`src/nodes/effect.ts`)
213
217
 
214
218
  **Graph node**: `EffectNode` (sink only)
@@ -247,13 +251,13 @@ A reactive array with stable keys and per-item reactivity. Each item becomes a `
247
251
 
248
252
  Collection implements two creation patterns that share the same `Collection<T>` interface:
249
253
 
250
- #### `createCollection(start, options?)` — externally driven
254
+ #### `createCollection(watched, options?)` — externally driven
251
255
 
252
256
  **Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
253
257
 
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 `[]`).
255
259
 
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.
257
261
 
258
262
  **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.
259
263
 
@@ -261,14 +265,12 @@ An externally-driven reactive collection with a watched lifecycle, mirroring `cr
261
265
 
262
266
  #### `deriveCollection(source, callback)` — internally derived
263
267
 
264
- **Graph node**: `MemoNode<string[]>` (source + sink, tracks keys not values)
268
+ **Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
265
269
 
266
270
  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.
267
271
 
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.
271
273
 
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.
273
275
 
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.
package/CHANGELOG.md ADDED
@@ -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.
package/CLAUDE.md CHANGED
@@ -86,10 +86,10 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
86
86
  ### Collection Architecture
87
87
 
88
88
  **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
91
91
  - `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
93
93
 
94
94
  **Derived Collections** (`deriveCollection`): Transformed from Lists or other Collections
95
95
  - Created via `list.deriveCollection(callback)` or `collection.deriveCollection(callback)`
@@ -124,7 +124,7 @@ Computed signals implement smart memoization:
124
124
 
125
125
  ## Resource Management with Watch Callbacks
126
126
 
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:
128
128
 
129
129
  ```typescript
130
130
  // Sensor: track external input with state updates
@@ -164,9 +164,9 @@ const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
164
164
  ```
165
165
 
166
166
  **Watch Lifecycle**:
167
- 1. First effect accesses signal → start/watched callback executed
167
+ 1. First effect accesses signal → watched callback executed
168
168
  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
170
170
 
171
171
  This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
172
172
 
@@ -230,7 +230,7 @@ const firstTodo = todoList.byKey('task1') // Access by stable key
230
230
 
231
231
  **Collection (`createCollection`)**:
232
232
  - 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
234
234
  - Same `Collection` interface — `.get()`, `.byKey()`, `.keys()`, `.deriveCollection()`
235
235
 
236
236
  ```typescript
@@ -263,6 +263,7 @@ const processed = todoList
263
263
  **Memo (`createMemo`)**:
264
264
  - Synchronous derived computations with memoization
265
265
  - Reducer pattern with previous value access
266
+ - Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
266
267
 
267
268
  ```typescript
268
269
  const doubled = createMemo(() => count.get() * 2)
@@ -283,6 +284,7 @@ const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
283
284
 
284
285
  **Task (`createTask`)**:
285
286
  - Async computations with automatic cancellation
287
+ - Optional `watched(invalidate)` callback for lazy external invalidation
286
288
 
287
289
  ```typescript
288
290
  const userData = createTask(async (prev, abort) => {
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.18.0
3
+ Version 0.18.1
4
4
 
5
5
  **Cause & Effect** is a reactive state management primitives library for TypeScript. It provides the foundational building blocks for managing complex, dynamic, composite, and asynchronous state — correctly and performantly — in a unified signal graph.
6
6
 
@@ -266,7 +266,7 @@ Lists have `.keys()`, `.add()`, and `.remove()` methods like stores. Additionall
266
266
 
267
267
  ### Collection
268
268
 
269
- A reactive collection with item-level memoization. Collections can be externally-driven (via a start callback) or derived from a List or another Collection.
269
+ A reactive collection with item-level memoization. Collections can be externally-driven (via a watched callback) or derived from a List or another Collection.
270
270
 
271
271
  **Externally-driven collections** receive data from external sources (WebSocket, Server-Sent Events, etc.) via `applyChanges()`:
272
272
 
@@ -277,7 +277,7 @@ const items = createCollection((applyChanges) => {
277
277
  const ws = new WebSocket('/items')
278
278
  ws.onmessage = (e) => {
279
279
  const { add, change, remove } = JSON.parse(e.data)
280
- applyChanges({ changed: true, add, change, remove })
280
+ applyChanges({ add, change, remove })
281
281
  }
282
282
  return () => ws.close()
283
283
  }, { keyConfig: item => item.id })
@@ -285,7 +285,7 @@ const items = createCollection((applyChanges) => {
285
285
  createEffect(() => console.log('Items:', items.get()))
286
286
  ```
287
287
 
288
- The start callback activates lazily when the collection is first accessed by an effect and cleans up when no effects are watching. Options include `value` for initial items (default `[]`) and `keyConfig` for key generation.
288
+ The watched callback activates lazily when the collection is first accessed by an effect and cleans up when no effects are watching. Options include `value` for initial items (default `[]`) and `keyConfig` for key generation.
289
289
 
290
290
  **Derived collections** transform Lists or other Collections via `.deriveCollection()`:
291
291
 
@@ -476,7 +476,7 @@ dispose() // Cleans up the effect and runs the returned cleanup
476
476
 
477
477
  ### Resource Management with Watch Callbacks
478
478
 
479
- Sensor and Collection signals use a **start callback** for lazy resource management. The callback runs when the signal is first accessed by an effect and the returned cleanup function runs when no effects are watching:
479
+ Sensor and Collection signals use a **watched callback** for lazy resource management. The callback runs when the signal is first accessed by an effect and the returned cleanup function runs when no effects are watching:
480
480
 
481
481
  ```js
482
482
  import { createSensor, createCollection, createEffect } from '@zeix/cause-effect'
@@ -517,11 +517,29 @@ const user = createStore({ name: 'Alice' }, {
517
517
  })
518
518
  ```
519
519
 
520
+ Memo and Task signals also support a `watched` option, but their callback receives an `invalidate` function that marks the signal dirty and triggers recomputation:
521
+
522
+ ```js
523
+ const changes = createMemo((prev) => {
524
+ const next = new Set(parent.querySelectorAll(selector))
525
+ // ... diff prev vs next ...
526
+ return { current: next, added, removed }
527
+ }, {
528
+ value: { current: new Set(), added: [], removed: [] },
529
+ watched: (invalidate) => {
530
+ const observer = new MutationObserver(() => invalidate())
531
+ observer.observe(parent, { childList: true, subtree: true })
532
+ return () => observer.disconnect()
533
+ }
534
+ })
535
+ ```
536
+
520
537
  This pattern is ideal for:
521
538
  - Event listeners that should only be active when data is being watched
522
539
  - Network connections that can be lazily established
523
540
  - Expensive computations that should pause when not needed
524
541
  - External subscriptions (WebSocket, Server-Sent Events, etc.)
542
+ - Computed signals that need to react to external events (DOM mutations, timers)
525
543
 
526
544
  ## Contributing & License
527
545
 
package/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/zeixcom/cause-effect",
3
+ "public_key": "pk_opatBUM6blxfIrNVkGw84"
4
+ }
@@ -0,0 +1,187 @@
1
+ import { createSensor, isSensor, type Sensor } from '..'
2
+
3
+ /* === Types === */
4
+
5
+ type ReservedWords =
6
+ | 'constructor'
7
+ | 'prototype'
8
+ | '__proto__'
9
+ | 'toString'
10
+ | 'valueOf'
11
+ | 'hasOwnProperty'
12
+ | 'isPrototypeOf'
13
+ | 'propertyIsEnumerable'
14
+ | 'toLocaleString'
15
+
16
+ type ComponentProp = Exclude<string, keyof HTMLElement | ReservedWords>
17
+ type ComponentProps = Record<ComponentProp, NonNullable<unknown>>
18
+
19
+ type Component<P extends ComponentProps> = HTMLElement & P
20
+
21
+ type UI = Record<string, Element | Sensor<Element[]>>
22
+
23
+ type EventType<K extends string> = K extends keyof HTMLElementEventMap
24
+ ? HTMLElementEventMap[K]
25
+ : Event
26
+
27
+ type EventHandler<
28
+ T extends {},
29
+ Evt extends Event,
30
+ U extends UI,
31
+ E extends Element,
32
+ > = (context: {
33
+ event: Evt
34
+ ui: U
35
+ target: E
36
+ prev: T
37
+ }) => T | void | Promise<void>
38
+
39
+ type EventHandlers<T extends {}, U extends UI, E extends Element> = {
40
+ [K in keyof HTMLElementEventMap]?: EventHandler<T, EventType<K>, U, E>
41
+ }
42
+
43
+ type ElementFromKey<U extends UI, K extends keyof U> = NonNullable<
44
+ U[K] extends Sensor<infer E extends Element>
45
+ ? E
46
+ : U[K] extends Element
47
+ ? U[K]
48
+ : never
49
+ >
50
+
51
+ type Parser<T extends {}, U extends UI> = (
52
+ ui: U,
53
+ value: string | null | undefined,
54
+ old?: string | null,
55
+ ) => T
56
+
57
+ type Reader<T extends {}, U extends UI> = (ui: U) => T
58
+ type Fallback<T extends {}, U extends UI> = T | Reader<T, U>
59
+
60
+ type ParserOrFallback<T extends {}, U extends UI> =
61
+ | Parser<T, U>
62
+ | Fallback<T, U>
63
+
64
+ /* === Internal === */
65
+
66
+ const pendingElements = new Set<Element>()
67
+ const tasks = new WeakMap<Element, () => void>()
68
+ let requestId: number | undefined
69
+
70
+ const runTasks = () => {
71
+ requestId = undefined
72
+ const elements = Array.from(pendingElements)
73
+ pendingElements.clear()
74
+ for (const element of elements) tasks.get(element)?.()
75
+ }
76
+
77
+ const requestTick = () => {
78
+ if (requestId) cancelAnimationFrame(requestId)
79
+ requestId = requestAnimationFrame(runTasks)
80
+ }
81
+
82
+ const schedule = (element: Element, task: () => void) => {
83
+ tasks.set(element, task)
84
+ pendingElements.add(element)
85
+ requestTick()
86
+ }
87
+
88
+ // High-frequency events that are passive by default and should be scheduled
89
+ const PASSIVE_EVENTS = new Set([
90
+ 'scroll',
91
+ 'resize',
92
+ 'mousewheel',
93
+ 'touchstart',
94
+ 'touchmove',
95
+ 'wheel',
96
+ ])
97
+
98
+ const isReader = <T extends {}, U extends UI>(
99
+ value: unknown,
100
+ ): value is Reader<T, U> => typeof value === 'function'
101
+
102
+ const getFallback = <T extends {}, U extends UI>(
103
+ ui: U,
104
+ fallback: ParserOrFallback<T, U>,
105
+ ): T => (isReader<T, U>(fallback) ? fallback(ui) : (fallback as T))
106
+
107
+ /* === Exported Functions === */
108
+
109
+ /**
110
+ * Produce an event-driven sensor from transformed event data
111
+ *
112
+ * @since 0.16.0
113
+ * @param {S} key - name of UI key
114
+ * @param {ParserOrFallback<T>} init - Initial value, reader or parser
115
+ * @param {EventHandlers<T, ElementFromSelector<S>, C>} events - Transformation functions for events
116
+ * @returns {Extractor<Sensor<T>, C>} Extractor function for value from event
117
+ */
118
+ const createEventsSensor =
119
+ <T extends {}, P extends ComponentProps, U extends UI, K extends keyof U>(
120
+ init: ParserOrFallback<T, U>,
121
+ key: K,
122
+ events: EventHandlers<T, U, ElementFromKey<U, K>>,
123
+ ): ((ui: U & { host: Component<P> }) => Sensor<T>) =>
124
+ (ui: U & { host: Component<P> }) => {
125
+ const { host } = ui
126
+ let value: T = getFallback(ui, init)
127
+ const targets = isSensor<ElementFromKey<U, K>[]>(ui[key])
128
+ ? ui[key].get()
129
+ : [ui[key] as ElementFromKey<U & { host: Component<P> }, K>]
130
+ const eventMap = new Map<string, EventListener>()
131
+
132
+ const getTarget = (
133
+ eventTarget: Node,
134
+ ): ElementFromKey<U, K> | undefined => {
135
+ for (const t of targets)
136
+ if (t.contains(eventTarget)) return t as ElementFromKey<U, K>
137
+ }
138
+
139
+ return createSensor<T>(
140
+ set => {
141
+ for (const [type, handler] of Object.entries(events)) {
142
+ const options = { passive: PASSIVE_EVENTS.has(type) }
143
+ const listener = (e: Event) => {
144
+ const eventTarget = e.target as Node
145
+ if (!eventTarget) return
146
+ const target = getTarget(eventTarget)
147
+ if (!target) return
148
+ e.stopPropagation()
149
+
150
+ const task = () => {
151
+ try {
152
+ const next = handler({
153
+ event: e as any,
154
+ ui,
155
+ target,
156
+ prev: value,
157
+ })
158
+ if (next == null || next instanceof Promise)
159
+ return
160
+ if (!Object.is(next, value)) {
161
+ value = next
162
+ set(next)
163
+ }
164
+ } catch (error) {
165
+ e.stopImmediatePropagation()
166
+ throw error
167
+ }
168
+ }
169
+ if (options.passive) schedule(host, task)
170
+ else task()
171
+ }
172
+ eventMap.set(type, listener)
173
+ host.addEventListener(type, listener, options)
174
+ }
175
+ return () => {
176
+ if (eventMap.size) {
177
+ for (const [type, listener] of eventMap)
178
+ host.removeEventListener(type, listener)
179
+ eventMap.clear()
180
+ }
181
+ }
182
+ },
183
+ { value },
184
+ )
185
+ }
186
+
187
+ export { createEventsSensor, type EventHandler, type EventHandlers }