@zeix/cause-effect 0.14.1 → 0.15.0

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.
Files changed (45) hide show
  1. package/README.md +256 -27
  2. package/biome.json +35 -0
  3. package/index.d.ts +32 -7
  4. package/index.dev.js +629 -0
  5. package/index.js +1 -1
  6. package/index.ts +41 -21
  7. package/package.json +6 -7
  8. package/src/computed.ts +30 -21
  9. package/src/diff.ts +136 -0
  10. package/src/effect.ts +59 -49
  11. package/src/match.ts +57 -0
  12. package/src/resolve.ts +58 -0
  13. package/src/scheduler.ts +3 -3
  14. package/src/signal.ts +48 -15
  15. package/src/state.ts +4 -3
  16. package/src/store.ts +325 -0
  17. package/src/util.ts +57 -5
  18. package/test/batch.test.ts +29 -25
  19. package/test/benchmark.test.ts +81 -45
  20. package/test/computed.test.ts +43 -39
  21. package/test/diff.test.ts +638 -0
  22. package/test/effect.test.ts +657 -49
  23. package/test/match.test.ts +378 -0
  24. package/test/resolve.test.ts +156 -0
  25. package/test/state.test.ts +33 -33
  26. package/test/store.test.ts +719 -0
  27. package/test/util/framework-types.ts +2 -2
  28. package/test/util/perf-tests.ts +2 -2
  29. package/test/util/reactive-framework.ts +1 -1
  30. package/tsconfig.json +9 -10
  31. package/types/index.d.ts +15 -0
  32. package/{src → types/src}/computed.d.ts +2 -2
  33. package/types/src/diff.d.ts +27 -0
  34. package/types/src/effect.d.ts +16 -0
  35. package/types/src/match.d.ts +21 -0
  36. package/types/src/resolve.d.ts +29 -0
  37. package/{src → types/src}/scheduler.d.ts +2 -2
  38. package/types/src/signal.d.ts +40 -0
  39. package/{src → types/src}/state.d.ts +1 -1
  40. package/types/src/store.d.ts +57 -0
  41. package/types/src/util.d.ts +15 -0
  42. package/types/test-new-effect.d.ts +1 -0
  43. package/src/effect.d.ts +0 -17
  44. package/src/signal.d.ts +0 -26
  45. package/src/util.d.ts +0 -7
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.14.1
3
+ Version 0.15.0
4
4
 
5
5
  **Cause & Effect** is a lightweight, reactive state management library for JavaScript applications. It uses fine-grained reactivity with signals to create predictable and efficient data flow in your app.
6
6
 
@@ -11,6 +11,7 @@ Version 0.14.1
11
11
  ### Core Concepts
12
12
 
13
13
  - **State signals**: Hold values that can be directly modified: `state()`
14
+ - **Store signals**: Hold objects of nested reactive properties: `store()`
14
15
  - **Computed signals**: Derive memoized values from other signals: `computed()`
15
16
  - **Effects**: Run side effects when signals change: `effect()`
16
17
 
@@ -19,9 +20,10 @@ Version 0.14.1
19
20
  - ⚡ **Reactive States**: Automatic updates when dependencies change
20
21
  - 🧩 **Composable**: Create a complex signal graph with a minimal API
21
22
  - ⏱️ **Async Ready**: Built-in `Promise` and `AbortController` support
22
- - 🛡️ **Error Handling**: Declare handlers for errors and unset states in effects
23
+ - 🛡️ **Error Handling**: Built-in helper functions for declarative error handling
24
+ - 🔧 **Helper Functions**: `resolve()` and `match()` for type-safe value extraction and pattern matching for suspense and error boundaries
23
25
  - 🚀 **Performance**: Batching and efficient dependency tracking
24
- - 📦 **Tiny**: Around 1kB gzipped, zero dependencies
26
+ - 📦 **Tiny**: Less than 3kB gzipped, zero dependencies
25
27
 
26
28
  ## Quick Start
27
29
 
@@ -73,6 +75,114 @@ document.querySelector('.increment').addEventListener('click', () => {
73
75
  // Click on button logs '25', '26', and so on
74
76
  ```
75
77
 
78
+ ### Store Signals
79
+
80
+ `store()` creates a mutable signal that holds an object with nested reactive properties. Each property automatically becomes its own signal with `.get()`, `.set()`, and `.update()` methods. Nested objects recursively become nested stores.
81
+
82
+ ```js
83
+ import { store, effect } from '@zeix/cause-effect'
84
+
85
+ const user = store({
86
+ name: 'Alice',
87
+ age: 30,
88
+ preferences: {
89
+ theme: 'dark',
90
+ notifications: true
91
+ }
92
+ })
93
+
94
+ // Individual properties are reactive
95
+ effect(() => {
96
+ console.log(`${user.name.get()} is ${user.age.get()} years old`)
97
+ })
98
+
99
+ // Nested properties work the same way
100
+ effect(() => {
101
+ console.log(`Theme: ${user.preferences.theme.get()}`)
102
+ })
103
+
104
+ // Update individual properties
105
+ user.age.update(v => v + 1) // Logs: "Alice is 31 years old"
106
+ user.preferences.theme.set('light') // Logs: "Theme: light"
107
+
108
+ // Watch the entire store
109
+ effect(() => {
110
+ console.log('User data:', user.get()) // Triggers on any nested change
111
+ })
112
+ ```
113
+
114
+ #### Dynamic Properties
115
+
116
+ Stores support dynamic property addition and removal at runtime using the `add()` and `remove()` methods:
117
+
118
+ ```js
119
+ import { store, effect } from '@zeix/cause-effect'
120
+
121
+ const settings = store({ autoSave: true })
122
+
123
+ // Add new properties at runtime
124
+ settings.add('timeout', 5000)
125
+ console.log(settings.timeout.get()) // 5000
126
+
127
+ // Adding an existing property has no effect
128
+ settings.add('autoSave', false) // Ignored - autoSave remains true
129
+
130
+ // Remove properties
131
+ settings.remove('timeout')
132
+ console.log(settings.timeout) // undefined
133
+
134
+ // Removing non-existent properties has no effect
135
+ settings.remove('nonExistent') // Safe - no error thrown
136
+ ```
137
+
138
+ The `add()` and `remove()` methods are optimized for performance:
139
+ - They bypass the full reconciliation process used by `set()` and `update()`
140
+ - They're perfect for frequent single-property additions/removals
141
+ - They trigger the same events and reactivity as other store operations
142
+
143
+ #### Store Events
144
+
145
+ Stores emit events when properties are added, changed, or removed. You can listen to these events using standard `addEventListener()`:
146
+
147
+ ```js
148
+ import { store } from '@zeix/cause-effect'
149
+
150
+ const user = store({ name: 'Alice', age: 30 })
151
+
152
+ // Listen for property additions
153
+ user.addEventListener('store-add', (event) => {
154
+ console.log('Added properties:', event.detail)
155
+ })
156
+
157
+ // Listen for property changes
158
+ user.addEventListener('store-change', (event) => {
159
+ console.log('Changed properties:', event.detail)
160
+ })
161
+
162
+ // Listen for property removals
163
+ user.addEventListener('store-remove', (event) => {
164
+ console.log('Removed properties:', event.detail)
165
+ })
166
+
167
+ // These will trigger the respective events:
168
+ user.add('email', 'alice@example.com') // Logs: "Added properties: { email: 'alice@example.com' }"
169
+ user.age.set(31) // Logs: "Changed properties: { age: 31 }"
170
+ user.remove('email') // Logs: "Removed properties: { email: UNSET }"
171
+ ```
172
+
173
+ Events are also fired when using `set()` or `update()` methods on the entire store:
174
+
175
+ ```js
176
+ // This will fire multiple events based on what changed
177
+ user.update(u => ({ ...u, name: 'Bob', city: 'New York' }))
178
+ // Logs: "Changed properties: { name: 'Bob' }"
179
+ // Logs: "Added properties: { city: 'New York' }"
180
+ ```
181
+
182
+ **When to use stores vs state:**
183
+ - **Use `store()`** for objects with reactive properties that you want to access individually
184
+ - **Use `state()`** for primitive values or objects you replace entirely
185
+
76
186
  ### Computed Signals vs. Functions
77
187
 
78
188
  #### When to Use Computed Signals
@@ -120,7 +230,7 @@ const isEven = () => !(count.get() % 2)
120
230
  4. Properly handles errors from failed requests
121
231
 
122
232
  ```js
123
- import { state, computed, effect } from '@zeix/cause-effect'
233
+ import { state, computed, effect, resolve, match } from '@zeix/cause-effect'
124
234
 
125
235
  const id = state(42)
126
236
  const data = computed(async abort => {
@@ -130,12 +240,13 @@ const data = computed(async abort => {
130
240
  return response.json()
131
241
  })
132
242
 
133
- // Handle all possible states
134
- effect({
135
- signals: [data],
136
- ok: json => console.log('Data loaded:', json),
137
- nil: () => console.log('Loading...'),
138
- err: error => console.error('Error:', error)
243
+ // Handle all possible states using resolve and match helpers
244
+ effect(() => {
245
+ match(resolve({ data }), {
246
+ ok: ({ data: json }) => console.log('Data loaded:', json),
247
+ nil: () => console.log('Loading...'),
248
+ err: errors => console.error('Error:', errors[0])
249
+ })
139
250
  })
140
251
 
141
252
  // When id changes, the previous request is automatically canceled
@@ -148,24 +259,64 @@ document.querySelector('button.next').addEventListener('click', () => {
148
259
 
149
260
  ## Effects and Error Handling
150
261
 
151
- Cause & Effect provides a robust way to handle side effects and errors through the `effect()` function, with three distinct paths:
262
+ The `effect()` function supports both synchronous and asynchronous callbacks:
152
263
 
153
- 1. **Ok**: When values are available
154
- 2. **Nil**: For loading/unset states (with async tasks)
155
- 3. **Err**: When errors occur during computation
264
+ ### Synchronous Effects
156
265
 
157
- This allows for declarative handling of all possible states:
266
+ ```js
267
+ import { state, effect } from '@zeix/cause-effect'
268
+
269
+ const count = state(42)
270
+ effect(() => {
271
+ console.log('Count changed:', count.get())
272
+ })
273
+ ```
274
+
275
+ ### Asynchronous Effects with AbortSignal
276
+
277
+ Async effect callbacks receive an `AbortSignal` parameter that automatically cancels when the effect re-runs or is cleaned up:
278
+
279
+ ```js
280
+ import { state, effect } from '@zeix/cause-effect'
281
+
282
+ const userId = state(1)
283
+ effect(async (abort) => {
284
+ try {
285
+ const response = await fetch(`/api/users/${userId.get()}`, { signal: abort })
286
+ const user = await response.json()
287
+ console.log('User loaded:', user)
288
+ } catch (error) {
289
+ if (!abort.aborted) {
290
+ console.error('Failed to load user:', error)
291
+ }
292
+ }
293
+ })
294
+ ```
295
+
296
+ ### Error Handling with Helper Functions
297
+
298
+ For more sophisticated error handling, use the `resolve()` and `match()` helper functions:
158
299
 
159
300
  ```js
160
- effect({
161
- signals: [data],
162
- ok: (value) => /* update UI when data is available */,
163
- nil: () => /* show loading state while pending */,
164
- err: (error) => /* show error message when computation fails */
301
+ import { state, computed, effect, resolve, match } from '@zeix/cause-effect'
302
+
303
+ const userId = state(1)
304
+ const userData = computed(async (abort) => {
305
+ const response = await fetch(`/api/users/${userId.get()}`, { signal: abort })
306
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
307
+ return response.json()
308
+ })
309
+
310
+ effect(() => {
311
+ match(resolve({ userData }), {
312
+ ok: ({ userData: user }) => console.log('User loaded:', user),
313
+ nil: () => console.log('Loading user...'),
314
+ err: errors => console.error('Error loading user:', errors[0])
315
+ })
165
316
  })
166
317
  ```
167
318
 
168
- Instead of using a single callback function, you can provide an object with an `ok` handler (required), plus optional `err` and `nil` handlers. Cause & Effect will automatically route to the appropriate handler based on the state of the signals. If not provided, Cause & Effect will assume `console.error` for `err` and a no-op for `nil`.
319
+ The `resolve()` function extracts values from signals and returns a discriminated union result, while `match()` provides pattern matching for handling different states declaratively.
169
320
 
170
321
  ## DOM Updates
171
322
 
@@ -258,7 +409,7 @@ Using Symbols for deduplication provides:
258
409
  Use `batch()` to group multiple signal updates, ensuring effects run only once after all changes are applied:
259
410
 
260
411
  ```js
261
- import { state, computed, effect, batch } from '@zeix/cause-effect'
412
+ import { state, computed, effect, batch, resolve, match } from '@zeix/cause-effect'
262
413
 
263
414
  // State: define an array of State<number>
264
415
  const signals = [state(2), state(3), state(5)]
@@ -271,11 +422,12 @@ const sum = computed(() => {
271
422
  return v
272
423
  })
273
424
 
274
- // Effect: handle the result
275
- effect({
276
- signals: [sum],
277
- ok: v => console.log('Sum:', v),
278
- err: error => console.error('Error:', error)
425
+ // Effect: handle the result with error handling
426
+ effect(() => {
427
+ match(resolve({ sum }), {
428
+ ok: ({ sum: v }) => console.log('Sum:', v),
429
+ err: errors => console.error('Error:', errors[0])
430
+ })
279
431
  })
280
432
 
281
433
  // Batch: apply changes to all signals in a single transaction
@@ -321,6 +473,83 @@ cleanup() // Logs: 'Cleanup' and unsubscribes from signal `user`
321
473
  user.set({ name: 'Bob', age: 28 }) // Won't trigger the effect anymore
322
474
  ```
323
475
 
476
+ ## Helper Functions
477
+
478
+ ### `resolve()` - Extract Signal Values
479
+
480
+ The `resolve()` function extracts values from multiple signals and returns a discriminated union result:
481
+
482
+ ```js
483
+ import { state, computed, resolve } from '@zeix/cause-effect'
484
+
485
+ const name = state('Alice')
486
+ const age = computed(() => 30)
487
+ const result = resolve({ name, age })
488
+
489
+ if (result.ok) {
490
+ console.log(result.values.name, result.values.age) // Type-safe access
491
+ } else if (result.pending) {
492
+ console.log('Loading...')
493
+ } else {
494
+ console.error('Errors:', result.errors)
495
+ }
496
+ ```
497
+
498
+ ### `match()` - Pattern Matching for Side Effects
499
+
500
+ The `match()` function provides pattern matching on resolve results for side effects:
501
+
502
+ ```js
503
+ import { resolve, match } from '@zeix/cause-effect'
504
+
505
+ match(resolve({ name, age }), {
506
+ ok: ({ name, age }) => document.title = `${name} (${age})`,
507
+ nil: () => document.title = 'Loading...',
508
+ err: errors => document.title = `Error: ${errors[0].message}`
509
+ })
510
+ ```
511
+
512
+ ### `diff()` - Compare Object Changes
513
+
514
+ The `diff()` function compares two objects and returns detailed information about what changed:
515
+
516
+ ```js
517
+ import { diff } from '@zeix/cause-effect'
518
+
519
+ const oldUser = { name: 'Alice', age: 30, city: 'Boston' }
520
+ const newUser = { name: 'Alice', age: 31, email: 'alice@example.com' }
521
+
522
+ const changes = diff(oldUser, newUser)
523
+ console.log(changes.changed) // true - something changed
524
+ console.log(changes.add) // { email: 'alice@example.com' }
525
+ console.log(changes.change) // { age: 31 }
526
+ console.log(changes.remove) // { city: UNSET }
527
+ ```
528
+
529
+ This function is used internally by stores to efficiently determine what changed and emit appropriate events.
530
+
531
+ ### `isEqual()` - Deep Equality Comparison
532
+
533
+ The `isEqual()` function performs deep equality comparison with circular reference detection:
534
+
535
+ ```js
536
+ import { isEqual } from '@zeix/cause-effect'
537
+
538
+ const obj1 = { name: 'Alice', preferences: { theme: 'dark' } }
539
+ const obj2 = { name: 'Alice', preferences: { theme: 'dark' } }
540
+ const obj3 = { name: 'Bob', preferences: { theme: 'dark' } }
541
+
542
+ console.log(isEqual(obj1, obj2)) // true - deep equality
543
+ console.log(isEqual(obj1, obj3)) // false - names differ
544
+
545
+ // Handles arrays, primitives, and complex nested structures
546
+ console.log(isEqual([1, 2, 3], [1, 2, 3])) // true
547
+ console.log(isEqual('hello', 'hello')) // true
548
+ console.log(isEqual({ a: [1, 2] }, { a: [1, 2] })) // true
549
+ ```
550
+
551
+ Both `diff()` and `isEqual()` include built-in protection against circular references and will throw a `CircularDependencyError` if cycles are detected.
552
+
324
553
  ## Contributing & License
325
554
 
326
555
  Feel free to contribute, report issues, or suggest improvements.
package/biome.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
3
+ "vcs": {
4
+ "enabled": false,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": false
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false,
10
+ "includes": ["**", "!index.js", "!index.dev.js", "!**/*.d.ts"]
11
+ },
12
+ "formatter": {
13
+ "enabled": true,
14
+ "indentStyle": "tab"
15
+ },
16
+ "linter": {
17
+ "enabled": true,
18
+ "rules": {
19
+ "recommended": true
20
+ }
21
+ },
22
+ "javascript": {
23
+ "formatter": {
24
+ "quoteStyle": "double"
25
+ }
26
+ },
27
+ "assist": {
28
+ "enabled": true,
29
+ "actions": {
30
+ "source": {
31
+ "organizeImports": "on"
32
+ }
33
+ }
34
+ }
35
+ }
package/index.d.ts CHANGED
@@ -1,11 +1,36 @@
1
1
  /**
2
2
  * @name Cause & Effect
3
- * @version 0.14.1
3
+ * @version 0.14.2
4
4
  * @author Esther Brunner
5
5
  */
6
- export { isFunction, CircularDependencyError } from './src/util';
7
- export { type Signal, type MaybeSignal, type SignalValues, UNSET, isSignal, isComputedCallback, toSignal, } from './src/signal';
8
- export { type State, TYPE_STATE, state, isState } from './src/state';
9
- export { type Computed, type ComputedCallback, TYPE_COMPUTED, computed, isComputed, } from './src/computed';
10
- export { type EffectMatcher, effect } from './src/effect';
11
- export { type Watcher, type Cleanup, type Updater, watch, subscribe, notify, flush, batch, observe, enqueue, } from './src/scheduler';
6
+ export {
7
+ type Computed,
8
+ type ComputedCallback,
9
+ computed,
10
+ isComputed,
11
+ TYPE_COMPUTED,
12
+ } from './src/computed'
13
+ export { type EffectMatcher, type MaybeCleanup, effect } from './src/effect'
14
+ export {
15
+ batch,
16
+ type Cleanup,
17
+ enqueue,
18
+ flush,
19
+ notify,
20
+ observe,
21
+ subscribe,
22
+ type Updater,
23
+ type Watcher,
24
+ watch,
25
+ } from './src/scheduler'
26
+ export {
27
+ isComputedCallback,
28
+ isSignal,
29
+ type MaybeSignal,
30
+ type Signal,
31
+ type SignalValues,
32
+ toSignal,
33
+ UNSET,
34
+ } from './src/signal'
35
+ export { isState, type State, state, TYPE_STATE } from './src/state'
36
+ export { CircularDependencyError, isFunction } from './src/util'