mutts 1.0.2 → 1.0.4

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 (104) hide show
  1. package/README.md +14 -6
  2. package/dist/chunks/{_tslib-C-cuVLvZ.js → _tslib-BgjropY9.js} +9 -1
  3. package/dist/chunks/_tslib-BgjropY9.js.map +1 -0
  4. package/dist/chunks/{_tslib-CMEnd0VE.esm.js → _tslib-Mzh1rNsX.esm.js} +9 -2
  5. package/dist/chunks/_tslib-Mzh1rNsX.esm.js.map +1 -0
  6. package/dist/chunks/{decorator-D4DU97Zg.js → decorator-DLvrD0UF.js} +42 -19
  7. package/dist/chunks/decorator-DLvrD0UF.js.map +1 -0
  8. package/dist/chunks/{decorator-GnHw1Az7.esm.js → decorator-DqiszP7i.esm.js} +42 -19
  9. package/dist/chunks/decorator-DqiszP7i.esm.js.map +1 -0
  10. package/dist/chunks/index-79Kk8D6e.esm.js +4857 -0
  11. package/dist/chunks/index-79Kk8D6e.esm.js.map +1 -0
  12. package/dist/chunks/index-GRBSx0mB.js +4908 -0
  13. package/dist/chunks/index-GRBSx0mB.js.map +1 -0
  14. package/dist/decorator.esm.js +1 -1
  15. package/dist/decorator.js +1 -1
  16. package/dist/destroyable.d.ts +1 -1
  17. package/dist/destroyable.esm.js +1 -1
  18. package/dist/destroyable.esm.js.map +1 -1
  19. package/dist/destroyable.js +1 -1
  20. package/dist/destroyable.js.map +1 -1
  21. package/dist/devtools/devtools.html +9 -0
  22. package/dist/devtools/devtools.js +5 -0
  23. package/dist/devtools/devtools.js.map +1 -0
  24. package/dist/devtools/manifest.json +8 -0
  25. package/dist/devtools/panel.css +72 -0
  26. package/dist/devtools/panel.html +31 -0
  27. package/dist/devtools/panel.js +13048 -0
  28. package/dist/devtools/panel.js.map +1 -0
  29. package/dist/eventful.esm.js +1 -1
  30. package/dist/eventful.js +1 -1
  31. package/dist/index.d.ts +18 -63
  32. package/dist/index.esm.js +4 -4
  33. package/dist/index.js +37 -11
  34. package/dist/index.js.map +1 -1
  35. package/dist/indexable.d.ts +187 -1
  36. package/dist/indexable.esm.js +197 -3
  37. package/dist/indexable.esm.js.map +1 -1
  38. package/dist/indexable.js +198 -2
  39. package/dist/indexable.js.map +1 -1
  40. package/dist/mutts.umd.js +1 -1
  41. package/dist/mutts.umd.js.map +1 -1
  42. package/dist/mutts.umd.min.js +1 -1
  43. package/dist/mutts.umd.min.js.map +1 -1
  44. package/dist/promiseChain.esm.js.map +1 -1
  45. package/dist/promiseChain.js.map +1 -1
  46. package/dist/reactive.d.ts +602 -97
  47. package/dist/reactive.esm.js +3 -3
  48. package/dist/reactive.js +32 -10
  49. package/dist/reactive.js.map +1 -1
  50. package/dist/std-decorators.esm.js +1 -1
  51. package/dist/std-decorators.js +1 -1
  52. package/docs/ai/api-reference.md +133 -0
  53. package/docs/ai/manual.md +105 -0
  54. package/docs/iterableWeak.md +646 -0
  55. package/docs/reactive/advanced.md +1280 -0
  56. package/docs/reactive/collections.md +767 -0
  57. package/docs/reactive/core.md +973 -0
  58. package/docs/reactive.md +21 -9545
  59. package/package.json +18 -5
  60. package/src/decorator.ts +266 -0
  61. package/src/destroyable.ts +199 -0
  62. package/src/eventful.ts +77 -0
  63. package/src/index.d.ts +9 -0
  64. package/src/index.ts +9 -0
  65. package/src/indexable.ts +484 -0
  66. package/src/introspection.ts +59 -0
  67. package/src/iterableWeak.ts +233 -0
  68. package/src/mixins.ts +123 -0
  69. package/src/promiseChain.ts +110 -0
  70. package/src/reactive/array.ts +414 -0
  71. package/src/reactive/change.ts +134 -0
  72. package/src/reactive/debug.ts +517 -0
  73. package/src/reactive/deep-touch.ts +268 -0
  74. package/src/reactive/deep-watch-state.ts +82 -0
  75. package/src/reactive/deep-watch.ts +168 -0
  76. package/src/reactive/effect-context.ts +94 -0
  77. package/src/reactive/effects.ts +1345 -0
  78. package/src/reactive/index.ts +76 -0
  79. package/src/reactive/interface.ts +223 -0
  80. package/src/reactive/map.ts +171 -0
  81. package/src/reactive/mapped.ts +130 -0
  82. package/src/reactive/memoize.ts +107 -0
  83. package/src/reactive/non-reactive-state.ts +49 -0
  84. package/src/reactive/non-reactive.ts +43 -0
  85. package/src/reactive/project.project.md +93 -0
  86. package/src/reactive/project.ts +335 -0
  87. package/src/reactive/proxy-state.ts +27 -0
  88. package/src/reactive/proxy.ts +289 -0
  89. package/src/reactive/record.ts +196 -0
  90. package/src/reactive/register.ts +421 -0
  91. package/src/reactive/set.ts +144 -0
  92. package/src/reactive/tracking.ts +101 -0
  93. package/src/reactive/types.ts +358 -0
  94. package/src/reactive/zone.ts +208 -0
  95. package/src/std-decorators.ts +217 -0
  96. package/src/utils.ts +117 -0
  97. package/dist/chunks/_tslib-C-cuVLvZ.js.map +0 -1
  98. package/dist/chunks/_tslib-CMEnd0VE.esm.js.map +0 -1
  99. package/dist/chunks/decorator-D4DU97Zg.js.map +0 -1
  100. package/dist/chunks/decorator-GnHw1Az7.esm.js.map +0 -1
  101. package/dist/chunks/index-DBScoeCX.esm.js +0 -1960
  102. package/dist/chunks/index-DBScoeCX.esm.js.map +0 -1
  103. package/dist/chunks/index-DOTmXL89.js +0 -1983
  104. package/dist/chunks/index-DOTmXL89.js.map +0 -1
@@ -0,0 +1,1280 @@
1
+ ## Atomic Operations
2
+
3
+ ### `atomic()` - Function Wrapper and Decorator
4
+
5
+ The `atomic` function wraps a function to batch all reactive effects triggered within it, ensuring effects run only once after the function completes. It can be used both as a function wrapper and as a decorator.
6
+
7
+ ```typescript
8
+ import { atomic, reactive, effect } from 'mutts/reactive'
9
+
10
+ const state = reactive({ a: 0, b: 0 })
11
+
12
+ effect(() => {
13
+ console.log('Values:', state.a, state.b)
14
+ })
15
+
16
+ // Wrap a function for atomic execution
17
+ const updateBoth = atomic((a, b) => {
18
+ state.a = a
19
+ state.b = b
20
+ // All effects triggered by these changes are batched
21
+ })
22
+
23
+ // Effect runs only once with final values
24
+ updateBoth(10, 20)
25
+
26
+ // atomic also works as a decorator
27
+ class CounterService {
28
+ @atomic
29
+ updateMultiple(newValue: number) {
30
+ state.a = newValue
31
+ this.performOtherUpdates()
32
+ }
33
+
34
+ performOtherUpdates() {
35
+ // Changes here are also batched
36
+ state.b = state.a + 1
37
+ }
38
+ }
39
+
40
+ const service = new CounterService()
41
+ service.updateMultiple(5) // Effect runs only once with final values
42
+ ```
43
+
44
+ The wrapped function preserves its signature (parameters and return value), and all effects triggered by reactive changes inside it are automatically batched.
45
+
46
+ ### `addBatchCleanup()` / `defer()` - Deferring Work to Avoid Cycles
47
+
48
+ When an effect needs to perform an action that would modify state the effect depends on, this can create a reactive cycle. The `addBatchCleanup` function (also exported as `defer` for semantic clarity) allows you to defer such work until after the current batch of effects completes.
49
+
50
+ ```typescript
51
+ import { addBatchCleanup, effect, reactive } from 'mutts/reactive'
52
+ // or use the semantic alias:
53
+ // import { defer } from 'mutts/reactive'
54
+
55
+ const state = reactive({
56
+ items: [],
57
+ processedCount: 0
58
+ })
59
+
60
+ effect(() => {
61
+ // This effect reads state.items
62
+ const itemCount = state.items.length
63
+
64
+ // If we modify state.processedCount here synchronously,
65
+ // it could trigger this effect again → potential cycle
66
+
67
+ // Instead, defer the mutation until after this effect completes
68
+ addBatchCleanup(() => {
69
+ state.processedCount = itemCount
70
+ })
71
+ })
72
+
73
+ state.items.push('new item')
74
+ // Effect runs, deferred callback runs after, no cycle
75
+ ```
76
+
77
+ **Common Use Case: Self-Triggering Effects**
78
+
79
+ A classic scenario is when an effect needs to create side effects that modify the state it reads:
80
+
81
+ ```typescript
82
+ class Hive {
83
+ advertisements = reactive({ wood: { demands: [], supplies: [] } })
84
+ movements = reactive([])
85
+
86
+ setupAdvertisementProcessing() {
87
+ effect(() => {
88
+ // Read advertisements
89
+ const ads = this.advertisements
90
+
91
+ // We need to create movements based on advertisements,
92
+ // but creating a movement modifies state that might trigger this effect
93
+
94
+ addBatchCleanup(() => {
95
+ // Deferred: runs after this effect completes
96
+ if (this.canMatchDemandWithSupply(ads.wood)) {
97
+ this.createMovement('wood', supplier, demander)
98
+ }
99
+ })
100
+ })
101
+ }
102
+
103
+ createMovement(good, from, to) {
104
+ // Modifies reactive state
105
+ this.movements.push({ good, from, to })
106
+ }
107
+ }
108
+ ```
109
+
110
+ **vs. `queueMicrotask` Anti-Pattern**
111
+
112
+ Before `addBatchCleanup` was widely known, developers sometimes used `queueMicrotask` as a workaround:
113
+
114
+ ```typescript
115
+ // ❌ Anti-pattern: Using queueMicrotask
116
+ effect(() => {
117
+ processAdvertisements()
118
+
119
+ queueMicrotask(() => {
120
+ createMovement() // Defers via event loop
121
+ })
122
+ })
123
+ ```
124
+
125
+ **Problems with `queueMicrotask`:**
126
+ - ❌ **Async timing**: Relies on JavaScript event loop, not reactive system
127
+ - ❌ **Hard to test**: Requires `await` in tests to flush microtasks
128
+ - ❌ **Unclear intent**: Not obvious this is for reactive cycle avoidance
129
+ - ❌ **No batch coordination**: Runs after event loop, not after reactive batch
130
+
131
+ **Use `addBatchCleanup` instead:**
132
+ ```typescript
133
+ // ✅ Correct: Using addBatchCleanup
134
+ effect(() => {
135
+ processAdvertisements()
136
+
137
+ addBatchCleanup(() => {
138
+ createMovement() // Defers to end of reactive batch
139
+ })
140
+ })
141
+ ```
142
+
143
+ **Benefits:**
144
+ - ✅ **Synchronous**: Runs immediately after batch, no event loop delay
145
+ - ✅ **Testable**: No need for `await` in tests
146
+ - ✅ **Semantic**: Clear that this is deferred work within reactive system
147
+ - ✅ **Batch-aware**: Respects reactive batch boundaries
148
+
149
+ **Execution Timing:**
150
+
151
+ `addBatchCleanup` callbacks run:
152
+ 1. After **all effects in the current batch** have executed
153
+ 2. In **FIFO order** (first added, first executed)
154
+ 3. **Before** control returns to the caller
155
+ 4. **Synchronously** (no microtask/setTimeout delay)
156
+
157
+ ```typescript
158
+ const state = reactive({ count: 0 })
159
+
160
+ console.log('1. Starting')
161
+
162
+ effect(() => {
163
+ console.log('2. Effect running, count:', state.count)
164
+
165
+ addBatchCleanup(() => {
166
+ console.log('5. Deferred work A')
167
+ })
168
+
169
+ addBatchCleanup(() => {
170
+ console.log('6. Deferred work B')
171
+ })
172
+
173
+ console.log('3. Effect ending')
174
+ })
175
+
176
+ console.log('4. After effect created')
177
+ // Deferred callbacks run here
178
+ console.log('7. All done')
179
+
180
+ // Output:
181
+ // 1. Starting
182
+ // 2. Effect running, count: 0
183
+ // 3. Effect ending
184
+ // 4. After effect created
185
+ // 5. Deferred work A
186
+ // 6. Deferred work B
187
+ // 7. All done
188
+ ```
189
+
190
+ **Outside of Batches:**
191
+
192
+ If `addBatchCleanup` is called when no batch is active, the callback executes **immediately**:
193
+
194
+ ```typescript
195
+ // Not inside an effect or atomic operation
196
+ addBatchCleanup(() => {
197
+ console.log('Runs immediately')
198
+ })
199
+ // Callback has already run by this point
200
+ ```
201
+
202
+ **Nested Batches:**
203
+
204
+ Callbacks added in nested batches are collected and run when the **outermost batch** completes:
205
+
206
+ ```typescript
207
+ effect(() => {
208
+ addBatchCleanup(() => console.log('Outer deferred'))
209
+
210
+ atomic(() => {
211
+ addBatchCleanup(() => console.log('Inner deferred'))
212
+ })()
213
+ })
214
+
215
+ // Output:
216
+ // Outer deferred
217
+ // Inner deferred
218
+ // (Both run after outer batch completes)
219
+ ```
220
+
221
+ **Error Handling:**
222
+
223
+ If a deferred callback throws an error, it's logged but doesn't stop other deferred callbacks from running:
224
+
225
+ ```typescript
226
+ effect(() => {
227
+ addBatchCleanup(() => {
228
+ throw new Error('First callback fails')
229
+ })
230
+
231
+ addBatchCleanup(() => {
232
+ console.log('Second callback still runs')
233
+ })
234
+ })
235
+
236
+ // Error is logged, but second callback executes
237
+ ```
238
+
239
+ **Best Practices:**
240
+
241
+ 1. **Use for consequence actions**: When an effect's consequences would trigger the effect again
242
+ 2. **Keep callbacks focused**: Each deferred callback should do one thing
243
+ 3. **Prefer defer alias**: `defer` is more semantic than `addBatchCleanup` for deferring work
244
+ 4. **Document why**: Add a comment explaining why deferral is needed
245
+
246
+ ```typescript
247
+ effect(() => {
248
+ processData()
249
+
250
+ // Defer movement creation to avoid triggering this effect again
251
+ // (createMovement modifies state that this effect reads)
252
+ defer(() => {
253
+ createMovement(data)
254
+ })
255
+ })
256
+ ```
257
+
258
+ ### `biDi()` - Bidirectional Binding
259
+
260
+
261
+ Creates a bidirectional binding between a reactive value and a non-reactive external value (like DOM elements), automatically preventing infinite loops.
262
+
263
+ ```typescript
264
+ import { biDi, reactive } from 'mutts/reactive'
265
+
266
+ const model = reactive({ value: '' })
267
+
268
+ // Bind to a DOM element value property
269
+ const provide = biDi(
270
+ (v) => {
271
+ // External setter: called when reactive value changes
272
+ inputElement.value = v
273
+ },
274
+ () => model.value, // Reactive getter
275
+ (v) => model.value = v // Reactive setter
276
+ )
277
+
278
+ // Handle user input (external changes)
279
+ inputElement.addEventListener('input', () => {
280
+ provide(inputElement.value) // Updates model.value without causing loop
281
+ })
282
+
283
+ // Changes to model.value also update the input element
284
+ model.value = 'new value' // inputElement.value is updated via biDi
285
+ ```
286
+
287
+ **When to use `biDi()`:**
288
+
289
+ - **Two-way data binding**: Connect reactive state to HTML form inputs, third-party libraries, or non-reactive APIs
290
+ - **Prevent circular updates**: Avoid infinite loops when external and reactive changes can trigger each other
291
+ - **Integrate external systems**: Bridge between reactive code and legacy/external code that can't be made reactive
292
+
293
+ **How it works:**
294
+
295
+ The `biDi()` function creates an effect that syncs reactive → external changes, and returns a function that handles external → reactive changes. The returned function uses atomic operations to suppress the circular effect, preventing infinite update loops.
296
+
297
+ **Alternative syntax:**
298
+
299
+ You can pass the reactive getter/setter as a single object instead of two separate parameters:
300
+
301
+ ```typescript
302
+ const provide = biDi(
303
+ (v) => inputElement.value = v,
304
+ { get: () => model.value, set: (v) => model.value = v }
305
+ )
306
+ ```
307
+
308
+ ## Advanced Effects
309
+
310
+ ### Recording Results inside Effects
311
+
312
+ If you want to capture derived values from an effect, push them from inside the effect body. Use the returned cleanup to manage resources between runs.
313
+
314
+ ```typescript
315
+ const state = reactive({ count: 0 })
316
+ const results: number[] = []
317
+
318
+ const stop = effect(() => {
319
+ results.push(state.count * 2)
320
+ return () => {
321
+ // cleanup between runs if needed
322
+ }
323
+ })
324
+
325
+ state.count = 5 // results: [0, 10]
326
+ stop()
327
+ ```
328
+
329
+ ### Effect Cleanup Functions
330
+
331
+ Effects can return cleanup functions that run before the next execution.
332
+
333
+ ```typescript
334
+ const state = reactive({ count: 0 })
335
+
336
+ effect(() => {
337
+ const interval = setInterval(() => {
338
+ console.log('Count:', state.count)
339
+ }, 1000)
340
+
341
+ return () => {
342
+ clearInterval(interval)
343
+ console.log('Cleaned up interval')
344
+ }
345
+ })
346
+
347
+ // When state.count changes, the cleanup runs first
348
+ state.count = 5
349
+ ```
350
+
351
+ ### Effect Lifecycle
352
+
353
+ 1. **Initial Run**: Effect runs immediately when created
354
+ 2. **Dependency Change**: When dependencies change, cleanup runs, then effect re-runs
355
+ 3. **Manual Stop**: Calling the cleanup function stops the effect permanently
356
+
357
+ ```typescript
358
+ const state = reactive({ count: 0 })
359
+
360
+ const stop = effect(() => {
361
+ console.log('Effect running, count:', state.count)
362
+ return () => console.log('Cleanup running')
363
+ })
364
+
365
+ console.log('Effect created')
366
+
367
+ state.count = 5
368
+ // Output:
369
+ // Effect created
370
+ // Effect running, count: 0
371
+ // Cleanup running
372
+ // Effect running, count: 5
373
+
374
+ stop() // Effect stops permanently
375
+ ```
376
+
377
+ ### Effect Arguments
378
+
379
+ Effects can accept additional arguments that are forwarded to the effect function. This is particularly useful in loops or when you need to pass context to the effect:
380
+
381
+ ```typescript
382
+ const items = reactive([
383
+ { id: 1, name: 'Item 1' },
384
+ { id: 2, name: 'Item 2' },
385
+ { id: 3, name: 'Item 3' }
386
+ ])
387
+
388
+ // Create effects for each item with the item index
389
+ const effectCleanups: (() => void)[] = []
390
+
391
+ for (let i = 0; i < items.length; i++) {
392
+ const cleanup = effect((access, index) => {
393
+ console.log(`Item ${index}:`, items[index].name)
394
+
395
+ // The index is captured in the closure and passed as argument
396
+ return () => console.log(`Cleaning up effect for item ${index}`)
397
+ }, i)
398
+
399
+ effectCleanups.push(cleanup)
400
+ }
401
+
402
+ // Later, clean up all effects
403
+ effectCleanups.forEach(cleanup => cleanup())
404
+ ```
405
+
406
+ ### Opaque Effects (Strict Mode)
407
+
408
+ By default, the reactive system uses "Deep Touch" optimization. If a reactive property is replaced with a new object/array that is structurally similar to the old one (e.g., swapping `[1, 2]` with a new `[1, 2]`), the system may skip re-running parent effects and only notify granular child updates. This optimization avoids unnecessary re-renders but can be problematic for effects that rely on strict object identity (like `memoize` or reference checks).
409
+
410
+ You can mark an effect as "opaque" to opt-out of this optimization. Opaque effects will **always** trigger when their dependencies change identity, regardless of content similarity.
411
+
412
+ ```typescript
413
+ // Standard effect (optimized)
414
+ effect(() => {
415
+ // If state.list is replaced by a similar list, this might NOT run
416
+ console.log('List changed', state.list)
417
+ })
418
+
419
+ // Opaque effect
420
+ effect(() => {
421
+ // This will ALWAYS run if state.list reference changes
422
+ console.log('List identity changed', state.list)
423
+ }, { opaque: true })
424
+ ```
425
+
426
+ This is automatically used by `memoize` to ensure cached results are invalidated when references change.
427
+
428
+ ### Watch Function
429
+
430
+ The `watch` function provides a more direct way to observe changes in reactive objects. It comes in two forms:
431
+
432
+ #### Watch with Value Function
433
+
434
+ ```typescript
435
+ const state = reactive({ count: 0, name: 'John' })
436
+
437
+ const stop = watch(
438
+ () => state.count, // Value function
439
+ (newValue, oldValue) => {
440
+ console.log(`Count changed from ${oldValue} to ${newValue}`)
441
+ }
442
+ )
443
+
444
+ state.count = 5 // Triggers: "Count changed from 0 to 5"
445
+ state.name = 'Jane' // No trigger (not watching name)
446
+ ```
447
+
448
+ #### Watch Object Properties
449
+
450
+ The second form of `watch` allows you to watch any property change on a reactive object:
451
+
452
+ ```typescript
453
+ const user = reactive({
454
+ name: 'John',
455
+ age: 30,
456
+ email: 'john@example.com'
457
+ })
458
+
459
+ const stop = watch(
460
+ user, // The reactive object to watch
461
+ () => {
462
+ console.log('Any property of user changed!')
463
+ console.log('Current user:', user)
464
+ }
465
+ )
466
+
467
+ user.name = 'Jane' // Triggers the callback
468
+ user.age = 31 // Triggers the callback
469
+ user.email = 'jane@example.com' // Triggers the callback
470
+ ```
471
+
472
+ #### Use Cases
473
+
474
+ **Object-level watching** is particularly useful for:
475
+
476
+ - **Form validation**: Watch all form fields for changes
477
+ - **Auto-save**: Save whenever any field in a document changes
478
+ - **Logging**: Track all changes to a state object
479
+ - **Dirty checking**: Detect if any property has been modified
480
+
481
+ ```typescript
482
+ const form = reactive({
483
+ firstName: '',
484
+ lastName: '',
485
+ email: '',
486
+ isValid: false
487
+ })
488
+
489
+ const stop = watch(form, () => {
490
+ // Auto-save whenever any field changes
491
+ saveForm(form)
492
+
493
+ // Update validation status
494
+ form.isValid = form.firstName && form.lastName && form.email
495
+ })
496
+
497
+ // Any change to firstName, lastName, or email will trigger auto-save
498
+ form.firstName = 'John'
499
+ form.lastName = 'Doe'
500
+ form.email = 'john.doe@example.com'
501
+ ```
502
+
503
+ #### Deep Watching
504
+
505
+ For both forms of `watch`, you can enable deep watching by passing `{ deep: true }` in the options:
506
+
507
+ ```typescript
508
+ const state = reactive({
509
+ user: {
510
+ name: 'John',
511
+ profile: { age: 30 }
512
+ }
513
+ })
514
+
515
+ // Deep watch - triggers on nested property changes
516
+ const stop = watch(state, () => {
517
+ console.log('Any nested property changed!')
518
+ }, { deep: true })
519
+
520
+ state.user.name = 'Jane' // Triggers
521
+ state.user.profile.age = 31 // Triggers (nested change)
522
+ ```
523
+
524
+ **Deep Watching Behavior:**
525
+
526
+ - **Unreactive objects are skipped**: Deep watching will not traverse into objects marked as unreactive using `@unreactive` or `unreactive()`
527
+ - **Collections are handled specially**:
528
+ - **Arrays**: All elements and length changes are tracked
529
+ - **Sets**: All values are tracked (keys are not separate in Sets)
530
+ - **Maps**: All values are tracked (keys are not tracked separately)
531
+ - **WeakSet/WeakMap**: Cannot be deep watched (not iterable), only replacement triggers
532
+ - **Circular references**: Handled safely - There is also a configurable depth limit
533
+ - **Performance**: Deep watching has higher overhead than shallow watching
534
+
535
+ ```typescript
536
+ // Example with collections
537
+ const state = reactive({
538
+ items: [1, 2, 3],
539
+ tags: new Set(['a', 'b']),
540
+ metadata: new Map([['key1', 'value1']])
541
+ })
542
+
543
+ const stop = watch(state, () => {
544
+ console.log('Collection changed')
545
+ }, { deep: true })
546
+
547
+ state.items.push(4) // Triggers
548
+ state.items[0] = 10 // Triggers
549
+ state.tags.add('c') // Triggers
550
+ state.metadata.set('key2', 'value2') // Triggers
551
+
552
+ // WeakSet/WeakMap only trigger on replacement
553
+ const weakSet = new WeakSet()
554
+ state.weakData = weakSet // Triggers (replacement)
555
+ // Changes to weakSet contents won't trigger (not trackable)
556
+ ```
557
+
558
+ #### Cleanup
559
+
560
+ Both forms of `watch` return a cleanup function:
561
+
562
+ ```typescript
563
+ const stop = watch(user, () => {
564
+ console.log('User changed')
565
+ })
566
+
567
+ // Later, stop watching
568
+ stop()
569
+ ```
570
+
571
+ ## Evolution Tracking
572
+
573
+ ### Understanding Object Evolution
574
+
575
+ The reactive system tracks how objects change over time, creating an "evolution history" that you can inspect.
576
+
577
+ ```typescript
578
+ import { getState } from './reactive'
579
+
580
+ const obj = reactive({ count: 0 })
581
+ let state = getState(obj)
582
+
583
+ effect(() => {
584
+ while ('evolution' in state) {
585
+ console.log('Change:', state.evolution)
586
+ state = state.next
587
+ }
588
+
589
+ console.log('Current count:', obj.count)
590
+ })
591
+
592
+ obj.count = 5
593
+ obj.count = 10
594
+ ```
595
+
596
+ ### `getState()` - Accessing Change History
597
+
598
+ Returns the current state object for tracking evolution.
599
+
600
+ ```typescript
601
+ function getState(obj: any): State
602
+ ```
603
+
604
+ **Returns:** A state object that does contain evolution information ( = `{}`) but will in a next evolution call if the object state has evolved.
605
+
606
+ ### Evolution Types
607
+
608
+ The system tracks different types of changes:
609
+
610
+ - **`add`**: Property was added
611
+ - **`set`**: Property value was changed
612
+ - **`del`**: Property was deleted
613
+ - **`bunch`**: Collection operation (array methods, map/set operations)
614
+
615
+ ```typescript
616
+ const obj = reactive({ count: 0 })
617
+ let state = getState(obj)
618
+
619
+ effect(() => {
620
+ let changes = []
621
+ while ('evolution' in state) {
622
+ changes.push(state.evolution)
623
+ state = state.next
624
+ }
625
+
626
+ if (changes.length > 0) {
627
+ console.log('Changes since last effect:', changes)
628
+ }
629
+ })
630
+
631
+ obj.count = 5 // { type: 'set', prop: 'count' }
632
+ obj.newProp = 'test' // { type: 'add', prop: 'newProp' }
633
+ delete obj.count // { type: 'del', prop: 'count' }
634
+
635
+ // Array operations
636
+ const array = reactive([1, 2, 3])
637
+ array.push(4) // { type: 'bunch', method: 'push' }
638
+ array.reverse() // { type: 'bunch', method: 'reverse' }
639
+ ```
640
+
641
+ ### Change History Patterns
642
+
643
+ Common patterns for using evolution tracking:
644
+
645
+ ```typescript
646
+ // Pattern 1: Count changes
647
+ let state = getState(obj)
648
+ effect(() => {
649
+ let changes = 0
650
+ while ('evolution' in state) {
651
+ changes++
652
+ state = state.next
653
+ }
654
+
655
+ if (changes > 0) {
656
+ console.log(`Detected ${changes} changes`)
657
+ state = getState(obj) // Reset for next run
658
+ }
659
+ })
660
+
661
+ // Pattern 2: Filter specific changes
662
+ let state = getState(obj)
663
+ effect(() => {
664
+ const relevantChanges = []
665
+ while ('evolution' in state) {
666
+ if (state.evolution.type === 'set') {
667
+ relevantChanges.push(state.evolution)
668
+ }
669
+ state = state.next
670
+ }
671
+
672
+ if (relevantChanges.length > 0) {
673
+ console.log('Property updates:', relevantChanges)
674
+ state = getState(obj)
675
+ }
676
+ })
677
+ ```
678
+
679
+ ## Prototype Chains and Pure Objects
680
+
681
+ The reactive system intelligently handles prototype chains, distinguishing between data prototypes (which should be tracked) and class prototypes (which should not). This enables powerful patterns like using instances as prototypes and working with pure objects created with `Object.create(null)`.
682
+
683
+ ### Pure Objects (Object.create(null))
684
+
685
+ Pure objects are objects created with `Object.create(null)` that have no prototype. These objects are useful for creating data structures without inheriting properties from `Object.prototype`.
686
+
687
+ ```typescript
688
+ // Create a pure object
689
+ const pure = reactive(Object.create(null) as any)
690
+ pure.x = 1
691
+ pure.y = 2
692
+
693
+ effect(() => {
694
+ console.log(`Pure object: x=${pure.x}, y=${pure.y}`)
695
+ })
696
+
697
+ pure.x = 10 // Triggers effect
698
+ ```
699
+
700
+ ### Prototype Chain Dependency Tracking
701
+
702
+ When accessing a property that doesn't exist on an object but exists in its prototype chain, the system automatically tracks dependencies on both the object and the prototype where the property is defined.
703
+
704
+ ```typescript
705
+ // Create parent with properties
706
+ const parent = reactive({ shared: 'value' })
707
+
708
+ // Create child that inherits from parent
709
+ const child = reactive(Object.create(parent))
710
+
711
+ effect(() => {
712
+ // Accesses 'shared' from parent through prototype chain
713
+ console.log(child.shared) // Tracks both child.shared AND parent.shared
714
+ })
715
+
716
+ // Changing parent property triggers the effect
717
+ parent.shared = 'new value' // Effect runs again
718
+ ```
719
+
720
+ ### Data Prototypes vs Class Prototypes
721
+
722
+ The system distinguishes between:
723
+
724
+ 1. **Data Prototypes**: Objects used as prototypes that don't have their own `constructor` property
725
+ - `Object.create({})` - Plain object as prototype
726
+ - `Object.create(instance)` - Reactive instance as prototype
727
+ - Pure object chains
728
+
729
+ 2. **Class Prototypes**: Class prototypes that have their own `constructor` property
730
+ - `Object.create(MyClass.prototype)` - Class prototype
731
+ - These are **not tracked** (we only care about data changes, not method overrides)
732
+
733
+ ### Pure Object Prototype Chains
734
+
735
+ You can create chains of pure objects for efficient data structures:
736
+
737
+ ```typescript
738
+ // Create root pure object
739
+ const root = reactive(Object.create(null) as any)
740
+ root.baseValue = 1
741
+
742
+ // Create child that inherits from root
743
+ const child = reactive(Object.create(root))
744
+ child.derivedValue = 2
745
+
746
+ // Create grandchild
747
+ const grandchild = reactive(Object.create(child))
748
+
749
+ effect(() => {
750
+ // Accesses baseValue from root through the chain
751
+ console.log(grandchild.baseValue) // 1
752
+ console.log(grandchild.derivedValue) // 2
753
+ })
754
+
755
+ root.baseValue = 10 // Triggers effect
756
+ ```
757
+
758
+ ### Using Instances as Prototypes
759
+
760
+ A powerful pattern is using reactive instances as prototypes. This allows sharing reactive state across multiple objects:
761
+
762
+ ```typescript
763
+ // Create a shared instance with reactive state
764
+ const sharedState = reactive({ count: 0, name: 'Shared' })
765
+
766
+ // Create multiple objects that share this instance as prototype
767
+ const obj1 = reactive(Object.create(sharedState))
768
+ const obj2 = reactive(Object.create(sharedState))
769
+
770
+ effect(() => {
771
+ // obj1 accesses count from sharedState prototype
772
+ console.log(`obj1.count: ${obj1.count}`)
773
+ })
774
+
775
+ effect(() => {
776
+ // obj2 accesses name from sharedState prototype
777
+ console.log(`obj2.name: ${obj2.name}`)
778
+ })
779
+
780
+ // Changing shared state triggers both effects
781
+ sharedState.count = 5 // Triggers first effect
782
+ sharedState.name = 'New' // Triggers second effect
783
+ ```
784
+
785
+ ### Shadowing in Prototype Chains
786
+
787
+ When an object defines its own property that exists in the prototype, it "shadows" the prototype property. The system only tracks the shadowing property, not the prototype property:
788
+
789
+ ```typescript
790
+ const parent = reactive({ value: 'parent' })
791
+ const child = reactive(Object.create(parent))
792
+ child.value = 'child' // Shadows parent.value
793
+
794
+ effect(() => {
795
+ console.log(child.value) // Tracks child.value, not parent.value
796
+ })
797
+
798
+ parent.value = 'parent-changed' // Does NOT trigger effect (shadowed)
799
+ child.value = 'child-changed' // Triggers effect
800
+ ```
801
+
802
+ ### Multi-Level Chains with Mixed Types
803
+
804
+ You can create complex chains mixing pure objects and normal objects:
805
+
806
+ ```typescript
807
+ // Pure object root
808
+ const root = reactive(Object.create(null) as any)
809
+ root.a = 1
810
+
811
+ // Normal object with pure parent
812
+ const mid = reactive(Object.create(root))
813
+ mid.b = 2
814
+
815
+ // Pure object with normal parent
816
+ const leaf = reactive(Object.create(mid))
817
+
818
+ effect(() => {
819
+ console.log(leaf.a) // From root
820
+ console.log(leaf.b) // From mid
821
+ })
822
+
823
+ root.a = 10 // Triggers effect
824
+ mid.b = 20 // Triggers effect
825
+ ```
826
+
827
+ ### Deep Touch with Prototype Chains
828
+
829
+ When recursive touch is performed on objects with prototype chains, the system compares both own properties and prototype chain properties. This ensures that changes to prototype-level properties are properly detected:
830
+
831
+ ```typescript
832
+ const ProtoA = reactive({ x: 1, y: 2 })
833
+ const ProtoB = reactive({ x: 10, y: 20 })
834
+
835
+ const A = reactive(Object.create(ProtoA))
836
+ const B = reactive(Object.create(ProtoB))
837
+
838
+ const C = reactive({ something: A })
839
+
840
+ effect(() => {
841
+ const val = C.something
842
+ effect(() => {
843
+ // Accesses x through prototype chain
844
+ const nested = val.x
845
+ })
846
+ })
847
+
848
+ // Deep touch replacement - compares prototype chains
849
+ C.something = B
850
+ // Nested effect runs because ProtoB.x differs from ProtoA.x
851
+ ```
852
+
853
+ ### Best Practices
854
+
855
+ 1. **Use pure objects for data-only structures**: Pure objects are ideal when you want to avoid inheriting methods from `Object.prototype`.
856
+
857
+ 2. **Use instance prototypes for shared state**: Creating multiple objects with the same instance as prototype is an efficient way to share reactive state.
858
+
859
+ 3. **Avoid class prototypes in data chains**: Don't use class prototypes (`MyClass.prototype`) in data prototype chains - the system won't track them.
860
+
861
+ 4. **Be mindful of shadowing**: Remember that properties defined on an object shadow prototype properties, so changes to the prototype won't trigger effects on the shadowing property.
862
+
863
+ ## Recursive Touching
864
+
865
+ When you replace a reactive object with another object that shares the same prototype (including `null`-prototype objects and class instances), the system performs a **recursive touch** instead of notifying watchers as if the entire branch changed. This means:
866
+
867
+ - Watchers attached to the container are *not* re-fired if the container's prototype did not change (this avoids unnecessary parent effect re-runs).
868
+ - Watchers attached to nested properties are re-evaluated only for keys that actually changed (added, removed, or whose values differ).
869
+ - For arrays, the behaviour stays index-oriented: replacing an element at index `i` fires a touch for that index (and `length` if needed) rather than diffing the element recursively. This preserves reorder detection.
870
+ - Prototype chain properties are compared when both objects have prototype chains, ensuring changes to prototype-level properties are detected.
871
+
872
+ **Integration with Prototype Chains:**
873
+
874
+ When comparing objects with prototype chains during recursive touch, the system:
875
+ - Compares own properties of both objects
876
+ - Walks and compares prototype chains (for data prototypes only, not class prototypes)
877
+ - Generates notifications for properties that differ at any level of the prototype chain
878
+ - Applies origin filtering to ensure only effects that came through the replacement property are notified
879
+
880
+ ### Example
881
+
882
+ ```typescript
883
+ const state = reactive({
884
+ nested: {
885
+ title: 'Hello',
886
+ meta: { views: 0 },
887
+ },
888
+ })
889
+
890
+ const containerWatcher = jest.fn()
891
+ const titleWatcher = jest.fn()
892
+ const viewsWatcher = jest.fn()
893
+
894
+ effect(()=> containerWatcher(state.nested))
895
+ effect(()=> titleWatcher(state.nested.title))
896
+ effect(()=> viewsWatcher(state.nested.meta.views))
897
+
898
+ // Replace nested with another object using the same shape/prototype
899
+ state.nested = {
900
+ title: 'Hello world',
901
+ meta: { views: 10 },
902
+ }
903
+
904
+ // containerWatcher runs only once (initial run)
905
+ // Deep touch avoids parent effects when only sub-properties change
906
+ expect(containerWatcher).toHaveBeenCalledTimes(1)
907
+
908
+ // titleWatcher reacts to the changed title
909
+ expect(titleWatcher).toHaveBeenCalledTimes(2)
910
+
911
+ // viewsWatcher reacts because the nested meta object changed recursively
912
+ expect(viewsWatcher).toHaveBeenCalledTimes(2)
913
+ ```
914
+
915
+ This behaviour keeps container-level watchers stable while still delivering fine-grained updates to nested effects—ideal when you replace data structures with freshly fetched objects that share the same prototype.
916
+
917
+ ### Origin Filtering
918
+
919
+ When recursive touch is triggered (e.g., `C.something = A` replaced with `C.something = B`), the system applies **origin filtering** to ensure only effects that came through the replacement property are notified:
920
+
921
+ ```typescript
922
+ const A = reactive({ x: 1, y: 2 })
923
+ const B = reactive({ x: 10, y: 20 })
924
+ const C = reactive({ something: A })
925
+ const D = reactive({ other: A })
926
+
927
+ let effect1Runs = 0
928
+ let effect2Runs = 0
929
+
930
+ // Effect1 depends on C.something
931
+ effect(() => {
932
+ effect1Runs++
933
+ const val = C.something
934
+ effect(() => {
935
+ // Nested effect accesses A.x through C.something
936
+ const nested = A.x
937
+ })
938
+ })
939
+
940
+ // Effect2 depends on A.x directly (not through C.something)
941
+ effect(() => {
942
+ effect2Runs++
943
+ const val = A.x
944
+ })
945
+
946
+ // Replace C.something
947
+ C.something = B
948
+
949
+ // Effect1's nested effect runs (it came through C.something)
950
+ // Effect2 does NOT run (it doesn't depend on C.something)
951
+ ```
952
+
953
+ **Key Points:**
954
+ - Effects that depend only on the container property (e.g., `C.something`) do **not** run
955
+ - Only nested effects that accessed properties through the container are notified
956
+ - Independent effects on the replaced object (e.g., direct dependency on `A.x`) are filtered out
957
+ - This minimizes unnecessary re-computations while keeping data consistent
958
+
959
+ **Note:** This recursive touching behavior can be disabled globally by setting `reactiveOptions.recursiveTouching = false`. When disabled, all object replacements will trigger parent effects regardless of prototype matching.
960
+
961
+ ### Why Not Deep Watching?
962
+
963
+ You might wonder why deep watching is implemented but not recommended for most use cases. The answer lies in understanding the tradeoffs:
964
+
965
+ #### The Deep Watching Approach
966
+
967
+ Traditional deep watching automatically tracks all nested properties:
968
+
969
+ ```typescript
970
+ const state = reactive({ user: { profile: { name: 'John' } } })
971
+
972
+ watch(state, () => {
973
+ console.log('Something changed')
974
+ }, { deep: true })
975
+
976
+ state.user.profile.name = 'Jane' // Triggers watch
977
+ ```
978
+
979
+ **Problems with deep watching:**
980
+ - **Performance overhead**: Every nested property access creates a dependency
981
+ - **Hidden dependencies**: Unclear which deep properties triggered the effect
982
+ - **Circular reference hazards**: Requires depth limits and visited tracking
983
+ - **Memory overhead**: Back-references for all nested objects
984
+ - **Over-notification**: Triggers when you might not care about a change
985
+
986
+ #### The Recursive Touch Approach
987
+
988
+ With recursive touching, you get fine-grained change detection without the overhead:
989
+
990
+ ```typescript
991
+ const state = reactive({ user: { profile: { name: 'John' } } })
992
+
993
+ // Replace the entire user object with fresh data
994
+ state.user = fetchUser() // Only notifies if actual values changed
995
+
996
+ // Or explicitly track what you need
997
+ effect(() => {
998
+ console.log(state.user.profile.name) // Only tracks this specific path
999
+ })
1000
+ ```
1001
+
1002
+ **Benefits:**
1003
+ - **Explicit dependencies**: You see exactly what properties you depend on
1004
+ - **Better performance**: Only tracks properties you actually read
1005
+ - **Automatic deduplication**: Reading `obj.toJSON()` marks all properties as used
1006
+ - **No circular reference issues**: Only touches what you explicitly access
1007
+ - **Clearer intent**: Your code shows what data matters
1008
+
1009
+ #### When to Use Each
1010
+
1011
+ **Use recursive touching (default) when:**
1012
+ - Building UI frameworks (most changes come from fresh data)
1013
+ - Working with fetched/stale data patterns
1014
+ - You want explicit dependency tracking
1015
+ - Performance matters
1016
+
1017
+ **Use deep watching when:**
1018
+ - You truly need to know about ANY change anywhere
1019
+ - Debugging complex state changes
1020
+ - Legacy integration where you can't change access patterns
1021
+ - You explicitly don't care about which property changed
1022
+
1023
+ #### Practical Example: Saving Objects
1024
+
1025
+ Deep watching makes everything reactive:
1026
+
1027
+ ```typescript
1028
+ // ❌ Deep watching - tracks too much
1029
+ const form = reactive({
1030
+ fields: { name: '', email: '' },
1031
+ meta: { lastSaved: Date.now() }
1032
+ })
1033
+ watch(form, saveToServer, { deep: true })
1034
+ form.meta.lastSaved = Date.now() // Unnecessary save triggered
1035
+ ```
1036
+
1037
+ Recursive touching with explicit tracking:
1038
+
1039
+ ```typescript
1040
+ // ✅ Explicit - only saves when form fields change
1041
+ const form = reactive({
1042
+ fields: { name: '', email: '' },
1043
+ meta: { lastSaved: Date.now() }
1044
+ })
1045
+ effect(() => {
1046
+ // Read the nested properties to track them
1047
+ const name = form.fields.name
1048
+ const email = form.fields.email
1049
+ saveToServer({ name, email })
1050
+ })
1051
+ form.meta.lastSaved = Date.now() // No save triggered
1052
+ form.fields.name = 'John' // Triggers save
1053
+ ```
1054
+
1055
+ Or if you need to serialize the whole form:
1056
+
1057
+ ```typescript
1058
+ // ✅ If you serialize, all properties are tracked automatically
1059
+ effect(() => {
1060
+ const data = JSON.stringify(form.fields) // Marks all fields as used
1061
+ saveToServer(data)
1062
+ })
1063
+ ```
1064
+
1065
+ **Bottom line:** Recursive touching gives you the granular control you need without deep watching's overhead, making it ideal for modern reactive applications.
1066
+
1067
+ ## Memoization
1068
+
1069
+ ### `memoize()`
1070
+
1071
+ `memoize(fn, maxArgs?)` caches the result of `fn` based on the identity of its arguments. All arguments must be WeakMap-compatible values (non-null objects, arrays, or symbols). Passing a primitive throws immediately to prevent inconsistent caching.
1072
+
1073
+ **Signature**
1074
+
1075
+ ```typescript
1076
+ import { memoize } from 'mutts/reactive'
1077
+
1078
+ type Memoizable = object | any[] | symbol
1079
+
1080
+ function memoize<Result>(
1081
+ fn: (...args: Memoizable[]) => Result,
1082
+ maxArgs?: number
1083
+ ): (...args: Memoizable[]) => Result
1084
+ ```
1085
+
1086
+ **Parameters**
1087
+
1088
+ - `fn`: compute function executed inside an effect. Any reactive reads it performs are tracked; when they change the cached value is discarded and recomputed on the next call.
1089
+ - `maxArgs` (optional): number of leading arguments to keep. Arguments beyond this index are ignored for caching **and** will not be forwarded to `fn`.
1090
+
1091
+ **Behaviour**
1092
+
1093
+ - Memoized wrappers are deduplicated per original function. Calling `memoize` multiple times with the same function returns the same memoized wrapper.
1094
+ - Cache entries live in nested `WeakMap`s keyed by each argument. When the last reference to a key disappears, the corresponding cache entry is eligible for garbage collection.
1095
+ - When dependencies touched inside `fn` change, the cache entry is removed and a `{ type: 'invalidate' }` touch is emitted.
1096
+ - Because caching relies on object identity, reuse parameter objects instead of recreating literals for every call.
1097
+
1098
+ **Basic usage**
1099
+
1100
+ ```typescript
1101
+ import { effect, memoize, reactive } from 'mutts/reactive'
1102
+
1103
+ const source = reactive({ value: 1 })
1104
+ const args = { node: source }
1105
+
1106
+ const double = memoize(({ node }: { node: typeof source }) => node.value * 2)
1107
+
1108
+ effect(() => {
1109
+ console.log(double(args))
1110
+ })
1111
+
1112
+ source.value = 5
1113
+ // -> cache invalidated, effect re-runs, memoized recomputes to 10
1114
+ ```
1115
+
1116
+ **Multiple arguments**
1117
+
1118
+ ```typescript
1119
+ const a = reactive({ value: 1 })
1120
+ const b = reactive({ value: 2 })
1121
+
1122
+ const sum = memoize((left: typeof a, right: typeof b) => left.value + right.value)
1123
+
1124
+ console.log(sum(a, b)) // 3
1125
+ b.value = 3
1126
+ console.log(sum(a, b)) // 4 (cache entry invalidated for the same argument pair)
1127
+ ```
1128
+
1129
+ **Limiting argument arity with `maxArgs`**
1130
+
1131
+ ```typescript
1132
+ const describe = memoize(
1133
+ (node: { id: string }, locale: { language: string }) => `${node.id}:${locale.language}`,
1134
+ 1
1135
+ )
1136
+
1137
+ describe({ id: 'x' } as any, { language: 'en' }) // locale is ignored, only the first argument is cached
1138
+ ```
1139
+
1140
+ Use `maxArgs` when the memoized function should only consider the first _n_ arguments. Subsequent arguments are ignored and not forwarded to `fn`.
1141
+
1142
+ ### Decorator usage
1143
+
1144
+ Apply `@memoize` to class getters or methods to share the same cache semantics. Getters cache per instance; methods cache per instance and argument tuple.
1145
+
1146
+ ```typescript
1147
+ import { memoize, reactive } from 'mutts/reactive'
1148
+
1149
+ class Example {
1150
+ state = reactive({ count: 0 })
1151
+
1152
+ @memoize
1153
+ get doubled() {
1154
+ return this.state.count * 2
1155
+ }
1156
+
1157
+ @memoize
1158
+ total(a: { value: number }, b: { value: number }) {
1159
+ return a.value + b.value + this.state.count
1160
+ }
1161
+ }
1162
+ ```
1163
+
1164
+ ### Working with `mapped()`
1165
+
1166
+ Pair `memoize()` with [`mapped()`](#mapped) to keep derived array elements stable across reorders (see [Identity-preserving mapped arrays](#identity-preserving-mapped-arrays)). Memoizing the mapper ensures each input object is processed at most once and its derived state persists while the input reference lives.
1167
+
1168
+ ## Debugging and Development
1169
+
1170
+ ### Cycle Detection
1171
+
1172
+ The reactive system automatically detects circular dependencies between effects. When one effect triggers another effect that eventually triggers the first effect again, a cycle is detected.
1173
+
1174
+ #### Cycle Detection Behavior
1175
+
1176
+ By default, cycles are detected and an error is thrown. You can configure the behavior using `reactiveOptions.cycleHandling`:
1177
+
1178
+ ```typescript
1179
+ import { reactiveOptions } from 'mutts/reactive'
1180
+
1181
+ // Options: 'throw' (default), 'warn', or 'break'
1182
+ reactiveOptions.cycleHandling = 'warn' // Warn instead of throwing
1183
+ reactiveOptions.cycleHandling = 'break' // Silently break the cycle
1184
+ ```
1185
+
1186
+ **Cycle handling modes:**
1187
+
1188
+ - **`'throw'`** (default): Throws a `ReactiveError` with a detailed cycle path when a cycle is detected
1189
+ - **`'warn'`**: Logs a warning message with the cycle path but continues execution (breaks the cycle)
1190
+ - **`'break'`**: Silently breaks the cycle without any message
1191
+ - **`'strict'`**: Checks the graph BEFORE execution. Prevents infinite loops at the source (higher overhead).
1192
+
1193
+ #### Cycle Error Messages
1194
+
1195
+ When a cycle is detected, the error message includes the full cycle path showing which effects form the cycle:
1196
+
1197
+ ```typescript
1198
+ const state = reactive({ a: 0, b: 0, c: 0 })
1199
+
1200
+ effect(() => {
1201
+ state.b = state.a + 1 // Effect A
1202
+ })
1203
+
1204
+ effect(() => {
1205
+ state.c = state.b + 1 // Effect B
1206
+ })
1207
+
1208
+ // This will throw with a detailed cycle path
1209
+ try {
1210
+ effect(() => {
1211
+ state.a = state.c + 1 // Effect C - creates cycle: A → B → C → A
1212
+ })
1213
+ } catch (e) {
1214
+ console.error(e.message)
1215
+ // "[reactive] Cycle detected: effectA → effectB → effectC → effectA"
1216
+ }
1217
+ ```
1218
+
1219
+ The cycle path shows the sequence of effects that form the circular dependency, making it easier to identify and fix the issue.
1220
+
1221
+ #### Common Cycle Patterns
1222
+
1223
+ **Direct cycle:**
1224
+ ```typescript
1225
+ const state = reactive({ a: 0, b: 0 })
1226
+
1227
+ effect(() => {
1228
+ state.b = state.a + 1 // Effect A
1229
+ })
1230
+
1231
+ effect(() => {
1232
+ state.a = state.b + 1 // Effect B - creates cycle: A → B → A
1233
+ })
1234
+ ```
1235
+
1236
+ **Indirect cycle:**
1237
+ ```typescript
1238
+ const state = reactive({ a: 0, b: 0, c: 0 })
1239
+
1240
+ effect(() => {
1241
+ state.b = state.a + 1 // Effect A
1242
+ })
1243
+
1244
+ effect(() => {
1245
+ state.c = state.b + 1 // Effect B
1246
+ })
1247
+
1248
+ effect(() => {
1249
+ state.a = state.c + 1 // Effect C - creates cycle: A → B → C → A
1250
+ })
1251
+ ```
1252
+
1253
+ #### Preventing Cycles
1254
+
1255
+ To avoid cycles, consider:
1256
+
1257
+ 1. **Separate read and write effects**: Don't have an effect that both reads and writes the same reactive properties
1258
+ 2. **Use `untracked()`**: For operations that shouldn't create dependencies
1259
+ 3. **Use `atomic()`**: To batch operations and prevent intermediate triggers
1260
+ 4. **Restructure logic**: Break circular dependencies by introducing intermediate state or computed values
1261
+
1262
+ **Example - Using `untracked()` to break cycles:**
1263
+ ```typescript
1264
+ const state = reactive({ count: 0 })
1265
+
1266
+ effect(() => {
1267
+ // Read count
1268
+ const current = state.count
1269
+
1270
+ // Write to count without creating a dependency cycle
1271
+ untracked(() => {
1272
+ if (current < 10) {
1273
+ state.count = current + 1
1274
+ }
1275
+ })
1276
+ })
1277
+ ```
1278
+
1279
+ **Note:** Self-loops (an effect reading and writing the same property, like `obj.prop++`) are automatically ignored and do not create dependency relationships or cycles.
1280
+