mutts 1.0.8 → 1.0.10

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 (195) hide show
  1. package/README.md +77 -35
  2. package/dist/browser.cjs +2144 -5355
  3. package/dist/browser.cjs.map +1 -1
  4. package/dist/browser.d.ts +2 -1655
  5. package/dist/browser.dev.cjs +110 -0
  6. package/dist/browser.dev.cjs.map +1 -0
  7. package/dist/browser.dev.d.ts +2 -0
  8. package/dist/browser.dev.esm.js +5 -0
  9. package/dist/browser.dev.esm.js.map +1 -0
  10. package/dist/browser.esm.js +20 -107
  11. package/dist/browser.esm.js.map +1 -1
  12. package/dist/chunks/{async-browser-CA0jPWIi.cjs → async-browser-BU_IfxYD.cjs} +19 -107
  13. package/dist/chunks/async-browser-BU_IfxYD.cjs.map +1 -0
  14. package/dist/chunks/{async-core-UqHzvJ-S.cjs → async-core-CRLKP3l-.cjs} +8 -4
  15. package/dist/chunks/async-core-CRLKP3l-.cjs.map +1 -0
  16. package/dist/chunks/{async-node-BYHuGTni.cjs → async-node-3PrbVAbB.cjs} +7 -12
  17. package/dist/chunks/async-node-3PrbVAbB.cjs.map +1 -0
  18. package/dist/chunks/index-CaaQQlPJ.esm.js +3174 -0
  19. package/dist/chunks/index-CaaQQlPJ.esm.js.map +1 -0
  20. package/dist/chunks/node-nKJBk8iJ.esm.js +96 -0
  21. package/dist/chunks/node-nKJBk8iJ.esm.js.map +1 -0
  22. package/dist/chunks/proxy-Dtg-bJ3T.cjs +3763 -0
  23. package/dist/chunks/proxy-Dtg-bJ3T.cjs.map +1 -0
  24. package/dist/chunks/proxy-r7lARftl.esm.js +3701 -0
  25. package/dist/chunks/proxy-r7lARftl.esm.js.map +1 -0
  26. package/dist/debug.cjs +980 -0
  27. package/dist/debug.cjs.map +1 -0
  28. package/dist/debug.d.ts +208 -0
  29. package/dist/debug.esm.js +950 -0
  30. package/dist/debug.esm.js.map +1 -0
  31. package/dist/devtools/manifest.json +1 -1
  32. package/dist/devtools/panel.html +1 -1
  33. package/dist/devtools/panel.js +107 -94
  34. package/dist/devtools/panel.js.map +1 -1
  35. package/dist/index.d.ts +1447 -13
  36. package/dist/mutts.umd.js +7167 -1
  37. package/dist/mutts.umd.js.map +1 -1
  38. package/dist/mutts.umd.min.js +1 -1
  39. package/dist/mutts.umd.min.js.map +1 -1
  40. package/dist/node.cjs +70 -66
  41. package/dist/node.cjs.map +1 -1
  42. package/dist/node.d.ts +2 -1
  43. package/dist/node.dev.cjs +110 -0
  44. package/dist/node.dev.cjs.map +1 -0
  45. package/dist/node.dev.d.ts +2 -0
  46. package/dist/node.dev.esm.js +6 -0
  47. package/dist/node.dev.esm.js.map +1 -0
  48. package/dist/node.esm.js +4 -103
  49. package/dist/node.esm.js.map +1 -1
  50. package/dist/{reactive/types.d.ts → types-W5vD6m2n.d.ts} +186 -93
  51. package/docs/ai/api-reference.md +12 -19
  52. package/docs/ai/manual.md +521 -17
  53. package/docs/events.md +57 -2
  54. package/docs/flavored.md +210 -0
  55. package/docs/reactive/advanced.md +314 -5
  56. package/docs/reactive/attend.md +142 -0
  57. package/docs/reactive/collections.md +76 -39
  58. package/docs/reactive/core.md +100 -14
  59. package/docs/reactive/debugging.md +123 -18
  60. package/docs/reactive/error-handling.md +121 -0
  61. package/docs/reactive/resource.md +125 -0
  62. package/docs/reactive/scan.md +247 -1
  63. package/docs/reactive.md +4 -1
  64. package/docs/std-decorators.md +102 -51
  65. package/docs/utils.md +70 -0
  66. package/docs/zone.md +3 -7
  67. package/package.json +64 -30
  68. package/dist/async/browser.d.ts +0 -2
  69. package/dist/async/browser.d.ts.map +0 -1
  70. package/dist/async/index.d.ts +0 -18
  71. package/dist/async/index.d.ts.map +0 -1
  72. package/dist/async/node.d.ts +0 -2
  73. package/dist/async/node.d.ts.map +0 -1
  74. package/dist/chunks/async-browser-CA0jPWIi.cjs.map +0 -1
  75. package/dist/chunks/async-core-UqHzvJ-S.cjs.map +0 -1
  76. package/dist/chunks/async-node-BYHuGTni.cjs.map +0 -1
  77. package/dist/chunks/index-DhaOVusv.esm.js +0 -6411
  78. package/dist/chunks/index-DhaOVusv.esm.js.map +0 -1
  79. package/dist/decorator.d.ts +0 -106
  80. package/dist/decorator.d.ts.map +0 -1
  81. package/dist/destroyable.d.ts +0 -87
  82. package/dist/destroyable.d.ts.map +0 -1
  83. package/dist/entry-browser.d.ts +0 -3
  84. package/dist/entry-browser.d.ts.map +0 -1
  85. package/dist/entry-node.d.ts +0 -3
  86. package/dist/entry-node.d.ts.map +0 -1
  87. package/dist/eventful.d.ts +0 -18
  88. package/dist/eventful.d.ts.map +0 -1
  89. package/dist/index.d.ts.map +0 -1
  90. package/dist/indexable.d.ts +0 -243
  91. package/dist/indexable.d.ts.map +0 -1
  92. package/dist/introspection.d.ts +0 -27
  93. package/dist/introspection.d.ts.map +0 -1
  94. package/dist/iterableWeak.d.ts +0 -53
  95. package/dist/iterableWeak.d.ts.map +0 -1
  96. package/dist/mixins.d.ts +0 -25
  97. package/dist/mixins.d.ts.map +0 -1
  98. package/dist/promiseChain.d.ts +0 -20
  99. package/dist/promiseChain.d.ts.map +0 -1
  100. package/dist/reactive/array.d.ts +0 -49
  101. package/dist/reactive/array.d.ts.map +0 -1
  102. package/dist/reactive/buffer.d.ts +0 -44
  103. package/dist/reactive/buffer.d.ts.map +0 -1
  104. package/dist/reactive/change.d.ts +0 -29
  105. package/dist/reactive/change.d.ts.map +0 -1
  106. package/dist/reactive/debug.d.ts +0 -111
  107. package/dist/reactive/debug.d.ts.map +0 -1
  108. package/dist/reactive/deep-touch.d.ts +0 -28
  109. package/dist/reactive/deep-touch.d.ts.map +0 -1
  110. package/dist/reactive/deep-watch-state.d.ts +0 -25
  111. package/dist/reactive/deep-watch-state.d.ts.map +0 -1
  112. package/dist/reactive/deep-watch.d.ts +0 -19
  113. package/dist/reactive/deep-watch.d.ts.map +0 -1
  114. package/dist/reactive/effect-context.d.ts +0 -7
  115. package/dist/reactive/effect-context.d.ts.map +0 -1
  116. package/dist/reactive/effects.d.ts +0 -151
  117. package/dist/reactive/effects.d.ts.map +0 -1
  118. package/dist/reactive/index.d.ts +0 -20
  119. package/dist/reactive/index.d.ts.map +0 -1
  120. package/dist/reactive/interface.d.ts +0 -64
  121. package/dist/reactive/interface.d.ts.map +0 -1
  122. package/dist/reactive/map.d.ts +0 -30
  123. package/dist/reactive/map.d.ts.map +0 -1
  124. package/dist/reactive/memoize.d.ts +0 -5
  125. package/dist/reactive/memoize.d.ts.map +0 -1
  126. package/dist/reactive/non-reactive-state.d.ts +0 -9
  127. package/dist/reactive/non-reactive-state.d.ts.map +0 -1
  128. package/dist/reactive/non-reactive.d.ts +0 -11
  129. package/dist/reactive/non-reactive.d.ts.map +0 -1
  130. package/dist/reactive/project.d.ts +0 -41
  131. package/dist/reactive/project.d.ts.map +0 -1
  132. package/dist/reactive/proxy-state.d.ts +0 -8
  133. package/dist/reactive/proxy-state.d.ts.map +0 -1
  134. package/dist/reactive/proxy.d.ts +0 -23
  135. package/dist/reactive/proxy.d.ts.map +0 -1
  136. package/dist/reactive/record.d.ts +0 -116
  137. package/dist/reactive/record.d.ts.map +0 -1
  138. package/dist/reactive/register.d.ts +0 -64
  139. package/dist/reactive/register.d.ts.map +0 -1
  140. package/dist/reactive/registry.d.ts +0 -20
  141. package/dist/reactive/registry.d.ts.map +0 -1
  142. package/dist/reactive/set.d.ts +0 -28
  143. package/dist/reactive/set.d.ts.map +0 -1
  144. package/dist/reactive/tracking.d.ts +0 -7
  145. package/dist/reactive/tracking.d.ts.map +0 -1
  146. package/dist/reactive/types.d.ts.map +0 -1
  147. package/dist/std-decorators.d.ts +0 -50
  148. package/dist/std-decorators.d.ts.map +0 -1
  149. package/dist/utils.d.ts +0 -49
  150. package/dist/utils.d.ts.map +0 -1
  151. package/dist/zone.d.ts +0 -40
  152. package/dist/zone.d.ts.map +0 -1
  153. package/docs/reactive/project.md +0 -93
  154. package/src/async/browser.ts +0 -319
  155. package/src/async/index.ts +0 -23
  156. package/src/async/node.ts +0 -104
  157. package/src/decorator.ts +0 -272
  158. package/src/destroyable.ts +0 -199
  159. package/src/entry-browser.ts +0 -5
  160. package/src/entry-node.ts +0 -5
  161. package/src/eventful.ts +0 -77
  162. package/src/index.d.ts +0 -12
  163. package/src/index.ts +0 -64
  164. package/src/indexable.ts +0 -526
  165. package/src/introspection.ts +0 -59
  166. package/src/iterableWeak.ts +0 -233
  167. package/src/mixins.ts +0 -123
  168. package/src/promiseChain.ts +0 -110
  169. package/src/reactive/array.ts +0 -507
  170. package/src/reactive/buffer.ts +0 -168
  171. package/src/reactive/change.ts +0 -138
  172. package/src/reactive/debug.ts +0 -517
  173. package/src/reactive/deep-touch.ts +0 -268
  174. package/src/reactive/deep-watch-state.ts +0 -82
  175. package/src/reactive/deep-watch.ts +0 -168
  176. package/src/reactive/effect-context.ts +0 -18
  177. package/src/reactive/effects.ts +0 -1382
  178. package/src/reactive/index.ts +0 -75
  179. package/src/reactive/interface.ts +0 -210
  180. package/src/reactive/map.ts +0 -158
  181. package/src/reactive/memoize.ts +0 -163
  182. package/src/reactive/non-reactive-state.ts +0 -49
  183. package/src/reactive/non-reactive.ts +0 -43
  184. package/src/reactive/project.ts +0 -453
  185. package/src/reactive/proxy-state.ts +0 -27
  186. package/src/reactive/proxy.ts +0 -264
  187. package/src/reactive/record.ts +0 -196
  188. package/src/reactive/register.ts +0 -419
  189. package/src/reactive/registry.ts +0 -59
  190. package/src/reactive/set.ts +0 -130
  191. package/src/reactive/tracking.ts +0 -44
  192. package/src/reactive/types.ts +0 -440
  193. package/src/std-decorators.ts +0 -217
  194. package/src/utils.ts +0 -301
  195. package/src/zone.ts +0 -138
@@ -0,0 +1,210 @@
1
+ # Flavored Functions
2
+
3
+ The `flavored` utility creates extensible functions with chainable property modifiers. It enables a fluent API where properties return specialized variants of the base function.
4
+
5
+ ## Overview
6
+
7
+ Flavored functions allow you to:
8
+ - Create property-based modifiers that transform how the function is called
9
+ - Chain multiple modifiers together
10
+ - Use either automatic option merging (`flavorOptions`) or custom argument transformation (`createFlavor`)
11
+ - Return hand-made functions for complete control (the generic case)
12
+
13
+ ## Basic Usage
14
+
15
+ ### Creating a Flavored Function
16
+
17
+ ```typescript
18
+ import { flavored, flavorOptions } from 'mutts'
19
+
20
+ const greet = flavored(
21
+ (name: string, options?: { loud?: boolean }) => {
22
+ const greeting = `Hello, ${name}!`
23
+ return options?.loud ? greeting.toUpperCase() : greeting
24
+ },
25
+ {
26
+ get loud() {
27
+ return flavorOptions(this, { loud: true })
28
+ }
29
+ }
30
+ )
31
+
32
+ greet('World') // "Hello, World!"
33
+ greet.loud('World') // "HELLO, WORLD!"
34
+ ```
35
+
36
+ ## API Reference
37
+
38
+ ### `flavored(fn, flavors)`
39
+
40
+ Creates a flavored function with chainable property modifiers.
41
+
42
+ **Parameters:**
43
+ - `fn` - The base function to flavor
44
+ - `flavors` - Object defining the flavor properties (getters or methods)
45
+
46
+ **Returns:** A proxy of the function with the flavor properties attached.
47
+
48
+ **Example:**
49
+ ```typescript
50
+ const effect = flavored(baseEffect, {
51
+ get opaque() {
52
+ return flavorOptions(this, { opaque: true })
53
+ },
54
+ named(name: string) {
55
+ return flavorOptions(this, { name })
56
+ }
57
+ })
58
+
59
+ effect(fn) // Basic usage
60
+ effect.opaque(fn) // With opaque option
61
+ effect.named('myEffect')(fn) // With name option
62
+ effect.opaque.named('x')(fn) // Chained
63
+ ```
64
+
65
+ ### `flavorOptions(fn, defaultOptions)`
66
+
67
+ Creates a flavored variant that merges options with the last argument.
68
+
69
+ **Use when:** The last argument is an options object that should be merged.
70
+
71
+ **Parameters:**
72
+ - `fn` - The base flavored function
73
+ - `defaultOptions` - Options to merge with provided options
74
+
75
+ **Example:**
76
+ ```typescript
77
+ const loudGreet = flavorOptions(greet, { loud: true })
78
+ loudGreet('World') // Uses { loud: true }
79
+ loudGreet('World', { prefix: 'Hi' }) // Merges both options
80
+ ```
81
+
82
+ ### `createFlavor(fn, transform)`
83
+
84
+ Creates a flavored variant with custom argument transformation.
85
+
86
+ **Use when:** You need to transform arguments before calling the base function.
87
+
88
+ **Parameters:**
89
+ - `fn` - The base flavored function
90
+ - `transform` - Function that receives and returns the arguments tuple
91
+
92
+ **Example:**
93
+ ```typescript
94
+ const doubleArgs = createFlavor(add, (a: number, b: number): [number, number] => {
95
+ return [a * 2, b * 2]
96
+ })
97
+ doubleArgs(3, 4) // Returns 14 (6 + 8)
98
+ ```
99
+
100
+ ## The Generic Case: Hand-Made Functions
101
+
102
+ When `flavorOptions` or `createFlavor` don't fit your needs, you can return a **hand-made function** directly. This gives you complete control over the behavior.
103
+
104
+ ### Basic Hand-Made Function
105
+
106
+ ```typescript
107
+ const fetch = flavored(fetchData, {
108
+ // Returns a completely custom function
109
+ withTimeout(timeout: number) {
110
+ return (url: string, options?: { retries?: number }) => {
111
+ return fetchData(url, { ...options, timeout })
112
+ }
113
+ }
114
+ })
115
+
116
+ // Usage
117
+ const fetchWith500ms = fetch.withTimeout(500)
118
+ fetchWith500ms('api.com') // Uses timeout: 500
119
+ ```
120
+
121
+ ### Re-Flavoring Hand-Made Functions
122
+
123
+ Hand-made functions can be re-flavored to enable further chaining:
124
+
125
+ ```typescript
126
+ const process = flavored(baseProcess, {
127
+ presetMultiplier(multiplier: number) {
128
+ // Create the preset function
129
+ const presetFn = (value: number, options?: { offset?: number }) => {
130
+ return baseProcess(value, { ...options, multiplier })
131
+ }
132
+ // Re-flavor it to enable chaining
133
+ return flavored(presetFn, {
134
+ get withOffset() {
135
+ return flavorOptions(this, { offset: 10 })
136
+ }
137
+ })
138
+ }
139
+ })
140
+
141
+ // Usage
142
+ const times3 = process.presetMultiplier(3)
143
+ times3(5) // 15 (5 * 3)
144
+ times3.withOffset(5) // 20 (5 * 3 + 5)
145
+ ```
146
+
147
+ ### When to Use Each Approach
148
+
149
+ | Approach | Use When |
150
+ |----------|----------|
151
+ | `flavorOptions` | You have an options object as the last argument |
152
+ | `createFlavor` | You need to transform arguments structurally |
153
+ | **Hand-made function** | You need complete control, different signature, or side effects |
154
+
155
+ ## Advanced Patterns
156
+
157
+ ### Chaining Multiple Modifiers
158
+
159
+ ```typescript
160
+ const createUser = flavored(createUserBase, {
161
+ get admin() {
162
+ return flavorOptions(this, { admin: true })
163
+ },
164
+ get verified() {
165
+ return flavorOptions(this, { verified: true })
166
+ }
167
+ })
168
+
169
+ createUser.admin.verified('Alice') // Both options applied
170
+ ```
171
+
172
+ ### Combining Approaches
173
+
174
+ ```typescript
175
+ const calculator = flavored(
176
+ (a: number, b: number, opts?: { multiply?: boolean }) => {
177
+ return opts?.multiply ? a * b : a + b
178
+ },
179
+ {
180
+ get multiply() {
181
+ return flavorOptions(this, { multiply: true })
182
+ },
183
+ double() {
184
+ return createFlavor(this, (a, b, opts?): [number, number, typeof opts] => {
185
+ return [a * 2, b * 2, opts]
186
+ })
187
+ }
188
+ }
189
+ )
190
+
191
+ calculator.multiply(3, 4) // 12
192
+ calculator.double()(3, 4) // 14 (6 + 8)
193
+ ```
194
+
195
+ ## TypeScript Considerations
196
+
197
+ Flavored functions use proxies and require type assertions for complex chaining scenarios:
198
+
199
+ ```typescript
200
+ const effectWithModifiers = flavored(effect, {
201
+ get opaque(): EffectWithModifiers {
202
+ return flavorOptions(this, { opaque: true }) as EffectWithModifiers
203
+ },
204
+ named(name: string): EffectWithModifiers {
205
+ return flavorOptions(this, { name }) as EffectWithModifiers
206
+ }
207
+ })
208
+ ```
209
+
210
+ The `as EffectWithModifiers` cast enables proper type inference for chained modifiers.
@@ -43,6 +43,43 @@ service.updateMultiple(5) // Effect runs only once with final values
43
43
 
44
44
  The wrapped function preserves its signature (parameters and return value), and all effects triggered by reactive changes inside it are automatically batched.
45
45
 
46
+ ### `atom()` - Immediate Atomic Execution
47
+
48
+ While `atomic()` **wraps** a function for later calls, `atom()` **runs** a function immediately and atomically. It always executes right away, even inside a nested batch.
49
+
50
+ ```typescript
51
+ import { atom, reactive, effect } from 'mutts/reactive'
52
+
53
+ const state = reactive({ a: 0, b: 0 })
54
+
55
+ effect(() => {
56
+ console.log('Values:', state.a, state.b)
57
+ })
58
+
59
+ // Execute multiple mutations atomically — effect fires only once
60
+ atom(() => {
61
+ state.a = 10
62
+ state.b = 20
63
+ })
64
+ ```
65
+
66
+ **`atom` vs `atomic`:**
67
+
68
+ | | `atom(fn)` | `atomic(fn)` |
69
+ |---|---|---|
70
+ | **When it runs** | Immediately | Returns a wrapped function for later calls |
71
+ | **Arguments** | `() => T` | `(...args) => T` (preserves signature) |
72
+ | **Use case** | One-shot batch of mutations | Reusable batched function / decorator |
73
+
74
+ ```typescript
75
+ // atom — runs now
76
+ atom(() => { state.a = 1; state.b = 2 })
77
+
78
+ // atomic — creates wrapper for later
79
+ const update = atomic((a, b) => { state.a = a; state.b = b })
80
+ update(1, 2) // runs when called
81
+ ```
82
+
46
83
  ### `addBatchCleanup()` / `defer()` - Deferring Work to Avoid Cycles
47
84
 
48
85
  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.
@@ -469,6 +506,31 @@ user.age = 31 // Triggers the callback
469
506
  user.email = 'jane@example.com' // Triggers the callback
470
507
  ```
471
508
 
509
+ #### Flavored Watch
510
+
511
+ The `watch` function is **flavored**, meaning it provides chainable properties for common options like `immediate` and `deep`:
512
+
513
+ ```typescript
514
+ const state = reactive({ count: 0, nested: { a: 1 } })
515
+
516
+ // Watch with immediate execution
517
+ watch.immediate(() => state.count, (v) => console.log(v))
518
+ // Prints: 0 immediately, then 1, 2...
519
+
520
+ // Deep watch
521
+ watch.deep(() => state.nested, (v) => console.log('Nested changed'))
522
+
523
+ // Combined flavors
524
+ watch.immediate.deep(() => state.nested, (v) => {
525
+ console.log('Immediate nested:', v)
526
+ })
527
+ ```
528
+
529
+ These flavors are a shorthand for passing options:
530
+ - `watch.immediate(...)` is equivalent to `watch(..., { immediate: true })`
531
+ - `watch.deep(...)` is equivalent to `watch(..., { deep: true })`
532
+ - `watch.immediate.deep(...)` is equivalent to `watch(..., { immediate: true, deep: true })`
533
+
472
534
  #### Use Cases
473
535
 
474
536
  **Object-level watching** is particularly useful for:
@@ -995,7 +1057,8 @@ state.user = fetchUser() // Only notifies if actual values changed
995
1057
 
996
1058
  // Or explicitly track what you need
997
1059
  effect(() => {
998
- console.log(state.user.profile.name) // Only tracks this specific path
1060
+ // Only tracks this specific path: Even if the `user` object changes, this effect will run only if the `profile.name` property has a different value
1061
+ console.log(state.user.profile.name)
999
1062
  })
1000
1063
  ```
1001
1064
 
@@ -1064,6 +1127,225 @@ effect(() => {
1064
1127
 
1065
1128
  **Bottom line:** Recursive touching gives you the granular control you need without deep watching's overhead, making it ideal for modern reactive applications.
1066
1129
 
1130
+ ## Choosing the Right Reactive Primitive
1131
+
1132
+ Mutts provides several ways to derive values from reactive state. They differ in **when** they recompute (eager vs lazy), **what** they return (raw value vs reactive proxy), and **how** downstream consumers track changes.
1133
+
1134
+ ### Comparison Table
1135
+
1136
+ | Primitive | Evaluation | Output | Trackable | Identity stable | Cleanup | Best for |
1137
+ | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
1138
+ | `effect(() => ...)` | Eager | Side-effect (void) | N/A | N/A | Auto (parent/GC) + Returned cleanup | Side effects, DOM updates |
1139
+ | `memoize(() => expr)` | **Lazy** | Raw value | Yes (return) | No (new value each call) | Auto (WeakMap GC) | Cached scalars, computed getters |
1140
+ | `memoize(fn)(args)` | **Lazy** | Raw value | Yes (return) | No | Auto (WeakMap GC) | Parameterized caching |
1141
+ | `lift(() => [...])` | Eager | Reactive array proxy | Yes (per-index) | **Yes** | `result[cleanup]()` | Derived collections (filter, map) |
1142
+ | `lift(() => ({...}))` | Eager | Reactive object proxy | Yes (per-prop) | **Yes** | `result[cleanup]()` | Derived objects, computed shapes |
1143
+ | `morph(source, fn)` | **Lazy** | Reactive proxy | Yes (per-key) | **Yes** | `cleanedBy` | Lazy per-element map with identity tracking |
1144
+ | `morph.pure(source, fn)` | **Lazy** | Reactive proxy | Yes (per-key) | **Yes** | `cleanedBy` | Same, but skips per-item dependency tracking |
1145
+ | `scan(source, fn, init)` | Eager | Reactive array | Yes (per-index) | **Yes** | `result[cleanup]()` | Running accumulations (prefix sums) |
1146
+ | `when(() => cond)` | Eager | Promise\<T\> | N/A | N/A | Auto (on resolve/timeout) | Awaiting a reactive condition |
1147
+ | `watch(source, cb)` | Eager | Callback (old/new) | N/A | N/A | Auto (parent/GC) + Returned cleanup | Observing specific changes |
1148
+
1149
+ ### Key distinctions
1150
+
1151
+ - **Lazy vs Eager**: `memoize` only recomputes when the result is read. Everything else recomputes immediately when dependencies change — even if nobody is consuming the output.
1152
+ - **Trackable**: Can downstream effects depend on the result? `memoize`'s return value is trackable because calling it runs inside an effect context. `lift`/`morph`/`scan` return reactive proxies where each property/index is independently trackable.
1153
+ - **Identity stable**: `lift`, `morph`, and `scan` return the **same proxy** across recomputations — only changed slots are updated. This is critical for downstream transformations or DOM reconciliation that relies on reference identity.
1154
+
1155
+ ### Common patterns
1156
+
1157
+ **Lazy computed scalar** (like Vue/Solid `computed`):
1158
+ ```typescript
1159
+ const total = memoize(() => state.price * state.quantity)
1160
+ effect(() => console.log(total())) // recomputes only when read
1161
+ ```
1162
+
1163
+ **Eager computed object** (trackable reactive proxy):
1164
+ ```typescript
1165
+ const profile = lift(() => ({
1166
+ displayName: user.name.toUpperCase(),
1167
+ isAdult: user.age >= 18,
1168
+ }))
1169
+ effect(() => console.log(profile.displayName)) // tracks .displayName only
1170
+ ```
1171
+
1172
+ **Derived filtered collection**:
1173
+ ```typescript
1174
+ const active = lift(() => items.filter(x => x.active))
1175
+ // active is a reactive array — project() or effects on active[i] work fine
1176
+ ```
1177
+
1178
+ **Per-element transform**:
1179
+ ```typescript
1180
+ const doubled = morph(numbers, ({ value }) => value * 2)
1181
+ // Each index has its own effect — changing numbers[3] only recomputes doubled[3]
1182
+ ```
1183
+
1184
+ ### `lift` vs `morph` — choosing the right primitive
1185
+
1186
+ Both `lift` and `morph` produce a stable reactive object from reactive inputs, but they differ fundamentally in **effect topology**:
1187
+
1188
+ | | `lift` | `morph` |
1189
+ |---|---|---|
1190
+ | **Effects** | 1 (single callback) | N+1 (1 outer + 1 per non-pure key) |
1191
+ | **Input** | Callback returning a plain object/array | Reactive source (array/record/Map) |
1192
+ | **Granularity** | Re-runs entire callback, diffs result | Per-key effect, only changed keys re-run |
1193
+ | **Key lifecycle** | Automatic (diff adds/removes keys) | Automatic (arrayDiff handles updates) |
1194
+ | **Execution** | Eager (recomputes immediately) | **Lazy** (recomputes on access) |
1195
+ | **Best for** | Computed shapes, small derived objects | Large collections with independent per-item logic |
1196
+
1197
+ **Rule of thumb**: if the source has **stable keys** and the mapping is **cheap**, prefer `lift` — one effect is far cheaper than N+1. Reserve `morph` for cases where per-key lifecycle matters (e.g. each key spawns side-effects, or the collection is large and only individual items change).
1198
+
1199
+ ```typescript
1200
+ // lift: 1 effect, diffs ~5 properties — ideal for derived shapes
1201
+ const profile = lift(() => ({
1202
+ displayName: user.name.toUpperCase(),
1203
+ isAdult: user.age >= 18,
1204
+ label: `${user.name} (${user.role})`,
1205
+ }))
1206
+
1207
+ // morph: 1000 items, only item[42] changes — only item[42]'s effect re-runs
1208
+ const results = morph(numbers, (val) => val * 2)
1209
+ ```
1210
+
1211
+ **Performance note**: each `morph` key creates a full reactive effect with its own dependency tracking. For a page with many small mappings (e.g. HTML element props), this compounds quickly. Converting small `morph` calls to `lift` can dramatically reduce total effect count.
1212
+
1213
+ ### When deep touching makes `lift` unnecessary
1214
+
1215
+ If you're simply **replacing** a reactive property with a new value of the same shape, deep touching already diffs element-by-element:
1216
+
1217
+ ```typescript
1218
+ state.items = fetchedItems // deep touch diffs old vs new per-index — no lift needed
1219
+ ```
1220
+
1221
+ `lift` is for **derived** data where there's no single property to assign to — the output is computed from scratch each time.
1222
+
1223
+ ## Morph
1224
+
1225
+ ### `morph(source, fn)`
1226
+
1227
+ `morph` creates a **lazy, identity-stable** reactive array by mapping each element of a source array through a callback. Unlike `lift` (which re-runs the entire callback and diffs the result), `morph` only computes an element when it is accessed, and tracks the source array via `arrayDiff` to efficiently handle insertions, removals, and moves.
1228
+
1229
+ **Signature**
1230
+
1231
+ ```typescript
1232
+ import { morph } from 'mutts/reactive'
1233
+
1234
+ function morph<I, O>(
1235
+ source: readonly I[] | (() => readonly I[]),
1236
+ fn: (arg: I) => O,
1237
+ options?: { pure?: boolean | ((i: I) => boolean) }
1238
+ ): O[]
1239
+ ```
1240
+
1241
+ **Parameters**
1242
+
1243
+ - `source`: a reactive array or a function returning one. Array mutations are tracked via `arrayDiff`.
1244
+ - `fn`: mapping callback. In the default (non-pure) mode, each element's computation runs inside its own effect, so reactive reads inside `fn` are tracked and will invalidate that element's cache when they change.
1245
+ - `options.pure`: controls per-item effect creation. `true` skips effects for all items (same as `morph.pure`). A **predicate function** `(i: I) => boolean` decides per-item: return `true` to skip the effect (pure), `false` to create one (reactive). The predicate receives the input item and is evaluated once per cache slot on first access.
1246
+
1247
+ **Behaviour**
1248
+
1249
+ - **Lazy**: elements are only computed when accessed (e.g. `result[0]`). Unaccessed indices remain `undefined` in the cache.
1250
+ - **Identity stable**: the returned reactive array proxy is the same object across source mutations. Only affected indices are invalidated.
1251
+ - **Per-item effects** (default): each accessed element gets its own effect. If `fn` reads reactive values beyond its argument, changes to those values invalidate and recompute only the affected elements.
1252
+ - **Cleanup**: the returned array is `cleanedBy` the internal morph effect. When the parent effect is disposed, the morph effect and all per-item effects are cleaned up.
1253
+
1254
+ **Basic usage**
1255
+
1256
+ ```typescript
1257
+ import { morph, reactive, effect } from 'mutts/reactive'
1258
+
1259
+ const items = reactive(['alice', 'bob', 'charlie'])
1260
+ const upper = morph(items, name => name.toUpperCase())
1261
+
1262
+ effect(() => {
1263
+ console.log(upper[0]) // "ALICE" — only element 0 is computed
1264
+ })
1265
+
1266
+ items.push('dave')
1267
+ console.log(upper[3]) // "DAVE"
1268
+
1269
+ items.splice(1, 1) // Remove 'bob' — indices shift, cache invalidated for affected positions
1270
+ ```
1271
+
1272
+ **With reactive callback dependencies**
1273
+
1274
+ ```typescript
1275
+ const source = reactive([1, 2, 3])
1276
+ const multiplier = reactive({ value: 2 })
1277
+
1278
+ const scaled = morph(source, x => x * multiplier.value)
1279
+
1280
+ console.log(scaled[0]) // 2
1281
+ multiplier.value = 10
1282
+ console.log(scaled[0]) // 10 — per-item effect re-ran
1283
+ ```
1284
+
1285
+ ### `morph.pure`
1286
+
1287
+ A flavored variant that skips per-item effects. Use when the callback is a **pure function** of its argument with no external reactive dependencies.
1288
+
1289
+ ```typescript
1290
+ const doubled = morph.pure(source, x => x * 2)
1291
+ ```
1292
+
1293
+ **Differences from default `morph`:**
1294
+
1295
+ | | `morph` | `morph.pure` |
1296
+ |---|---|---|
1297
+ | **Per-item effects** | Yes — tracks `fn`'s reactive reads | No — `fn` runs once, result is cached |
1298
+ | **Callback dependency invalidation** | Automatic | None — stale if `fn` reads reactive values |
1299
+ | **Non-reactive source optimization** | Returns reactive proxy | Returns plain `source.map(fn)` |
1300
+ | **Best for** | Callbacks that read reactive state | Pure transforms (`x => x * 2`, `e => e.render()`) |
1301
+
1302
+ ### `pure` predicate — selective per-item mode
1303
+
1304
+ When `pure` is a function, each item is individually classified as pure or reactive on first access:
1305
+
1306
+ ```typescript
1307
+ const result = morph(source, x => x * factor.value, {
1308
+ pure: (x: number) => x > 2
1309
+ })
1310
+ ```
1311
+
1312
+ Items where the predicate returns `true` are computed once (no effect, no dependency tracking). Items where it returns `false` get a per-item effect that reacts to external dependency changes.
1313
+
1314
+ ```typescript
1315
+ const source = reactive(['static', 'dynamic', 'static'])
1316
+ const suffix = reactive({ value: '!' })
1317
+
1318
+ const result = morph(source, s => s + suffix.value, {
1319
+ pure: (s: string) => s === 'static'
1320
+ })
1321
+
1322
+ result[0] // 'static!' — pure, no effect
1323
+ result[1] // 'dynamic!' — reactive, tracks suffix.value
1324
+
1325
+ suffix.value = '?'
1326
+ result[0] // 'static!' — stale (pure)
1327
+ result[1] // 'dynamic?' — updated (reactive)
1328
+ ```
1329
+
1330
+ New items added to the source are classified by the predicate at first access. This is useful when some items are known to be static while others depend on external reactive state.
1331
+
1332
+ **Example: pure vs reactive**
1333
+
1334
+ ```typescript
1335
+ const source = reactive([1, 2])
1336
+ const factor = reactive({ value: 10 })
1337
+
1338
+ // Reactive: tracks factor.value per element
1339
+ const reactive = morph(source, x => x * factor.value)
1340
+
1341
+ // Pure: ignores factor.value changes
1342
+ const pure = morph.pure(source, x => x * factor.value)
1343
+
1344
+ factor.value = 20
1345
+ reactive[0] // 20 — recomputed
1346
+ pure[0] // 10 — stale, no per-item effect to invalidate
1347
+ ```
1348
+
1067
1349
  ## Memoization
1068
1350
 
1069
1351
  ### `memoize()`
@@ -1139,9 +1421,30 @@ describe({ id: 'x' } as any, { language: 'en' }) // locale is ignored, only the
1139
1421
 
1140
1422
  Use `maxArgs` when the memoized function should only consider the first _n_ arguments. Subsequent arguments are ignored and not forwarded to `fn`.
1141
1423
 
1424
+ ### `memoize.lenient`
1425
+
1426
+ A flavored variant that gracefully handles non-WeakKey arguments (primitives, `null`, `undefined`). Instead of throwing, it falls back to recomputing the function without caching.
1427
+
1428
+ ```typescript
1429
+ import { memoize } from 'mutts/reactive'
1430
+
1431
+ const process = memoize.lenient((value: string | { data: string }) => {
1432
+ return typeof value === 'string' ? value.toUpperCase() : value.data
1433
+ })
1434
+
1435
+ const obj = { data: 'hello' }
1436
+ process(obj) // Cached (object is a WeakKey)
1437
+ process(obj) // Returns cached result
1438
+
1439
+ process('world') // Recomputed each time (string is not a WeakKey)
1440
+ process('world') // Recomputed again — no caching for primitives
1441
+ ```
1442
+
1443
+ This is useful when a memoized function may receive both objects and primitives, and you want caching where possible without errors for the rest.
1444
+
1142
1445
  ### Decorator usage
1143
1446
 
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.
1447
+ Apply `@memoize` (or `@memoize.lenient`) to class getters or methods to share the same cache semantics. Getters cache per instance; methods cache per instance and argument tuple.
1145
1448
 
1146
1449
  ```typescript
1147
1450
  import { memoize, reactive } from 'mutts/reactive'
@@ -1159,6 +1462,13 @@ class Example {
1159
1462
  return a.value + b.value + this.state.count
1160
1463
  }
1161
1464
  }
1465
+
1466
+ class Flexible {
1467
+ @memoize.lenient
1468
+ process(value: string) {
1469
+ return value.toUpperCase() // No throw — recomputes each time
1470
+ }
1471
+ }
1162
1472
  ```
1163
1473
 
1164
1474
  ## Debugging and Development
@@ -1169,8 +1479,7 @@ For a full guide on debugging, including cycle detection and memoization discrep
1169
1479
 
1170
1480
  ### Quick Summary
1171
1481
 
1172
- - **Cycle Detection**: Automatically catch circular dependencies via `reactiveOptions.cycleHandling`. Note: Instant mathematical detection requires choosing a mode other than `'none'`.
1173
- - **Flat Mode**: The default `reactiveOptions.cycleHandling = 'none'` provides maximum performance in high-frequency update scenarios by disabling graph maintenance.
1482
+ - **Cycle Detection**: Automatically catch circular dependencies via `reactiveOptions.cycleHandling`. Note: Instant mathematical detection requires choosing `'development'` or `'debug'` mode.
1483
+ - **Production Mode**: The default `reactiveOptions.cycleHandling` is set to `'production'`, providing maximum performance in high-frequency update scenarios by disabling graph maintenance.
1174
1484
  - **Memoization Discrepancy**: Detect "missing dependencies" by running computations twice during development using `reactiveOptions.onMemoizationDiscrepancy`.
1175
1485
  - **Global Hooks**: Use `reactiveOptions.touched`, `enter`, and `leave` to observe system activity.
1176
-