mutts 1.0.7 → 1.0.9

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 (191) hide show
  1. package/README.md +72 -17
  2. package/dist/browser.cjs +3111 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.ts +1391 -1
  5. package/dist/browser.esm.js +264 -35
  6. package/dist/browser.esm.js.map +1 -1
  7. package/dist/chunks/async-browser-lvzLOCgk.cjs +297 -0
  8. package/dist/chunks/async-browser-lvzLOCgk.cjs.map +1 -0
  9. package/dist/chunks/async-core-CRLKP3l-.cjs +29 -0
  10. package/dist/chunks/async-core-CRLKP3l-.cjs.map +1 -0
  11. package/dist/chunks/async-node-C3DeIb0y.cjs +96 -0
  12. package/dist/chunks/async-node-C3DeIb0y.cjs.map +1 -0
  13. package/dist/chunks/index-2vea86wD.esm.js +3011 -0
  14. package/dist/chunks/index-2vea86wD.esm.js.map +1 -0
  15. package/dist/chunks/index-CtA2AWl3.esm.js +4376 -0
  16. package/dist/chunks/index-CtA2AWl3.esm.js.map +1 -0
  17. package/dist/chunks/index-VTO-b2vR.cjs +4461 -0
  18. package/dist/chunks/index-VTO-b2vR.cjs.map +1 -0
  19. package/dist/debug/debug.d.ts +122 -0
  20. package/dist/debug/debug.d.ts.map +1 -0
  21. package/dist/debug/index.d.ts +4 -0
  22. package/dist/debug/index.d.ts.map +1 -0
  23. package/dist/debug/lineage-panel.d.ts +5 -0
  24. package/dist/debug/lineage-panel.d.ts.map +1 -0
  25. package/dist/debug/lineage.d.ts +79 -0
  26. package/dist/debug/lineage.d.ts.map +1 -0
  27. package/dist/debug.cjs +35 -0
  28. package/dist/debug.cjs.map +1 -0
  29. package/dist/debug.d.ts +209 -0
  30. package/dist/debug.esm.js +2 -0
  31. package/dist/debug.esm.js.map +1 -0
  32. package/dist/devtools/devtool/devtools.d.ts +1 -0
  33. package/dist/devtools/devtool/devtools.d.ts.map +1 -0
  34. package/dist/devtools/devtool/panel.d.ts +2 -0
  35. package/dist/devtools/devtool/panel.d.ts.map +1 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/mutts.umd.js +1 -1
  38. package/dist/mutts.umd.js.map +1 -1
  39. package/dist/mutts.umd.min.js +1 -1
  40. package/dist/mutts.umd.min.js.map +1 -1
  41. package/dist/node.cjs +107 -0
  42. package/dist/node.cjs.map +1 -0
  43. package/dist/node.d.ts +2 -2
  44. package/dist/node.esm.js +86 -33
  45. package/dist/node.esm.js.map +1 -1
  46. package/dist/src/async/browser.d.ts +2 -0
  47. package/dist/src/async/browser.d.ts.map +1 -0
  48. package/dist/src/async/index.d.ts +19 -0
  49. package/dist/src/async/index.d.ts.map +1 -0
  50. package/dist/src/async/node.d.ts +2 -0
  51. package/dist/src/async/node.d.ts.map +1 -0
  52. package/dist/src/decorator.d.ts +106 -0
  53. package/dist/src/decorator.d.ts.map +1 -0
  54. package/dist/src/destroyable.d.ts +87 -0
  55. package/dist/src/destroyable.d.ts.map +1 -0
  56. package/dist/src/entry-browser.d.ts +3 -0
  57. package/dist/src/entry-browser.d.ts.map +1 -0
  58. package/dist/src/entry-node.d.ts +3 -0
  59. package/dist/src/entry-node.d.ts.map +1 -0
  60. package/dist/src/eventful.d.ts +20 -0
  61. package/dist/src/eventful.d.ts.map +1 -0
  62. package/dist/src/flavored.d.ts +33 -0
  63. package/dist/src/flavored.d.ts.map +1 -0
  64. package/dist/src/index.d.ts +14 -0
  65. package/dist/src/index.d.ts.map +1 -0
  66. package/dist/src/indexable.d.ts +243 -0
  67. package/dist/src/indexable.d.ts.map +1 -0
  68. package/dist/src/introspection.d.ts +27 -0
  69. package/dist/src/introspection.d.ts.map +1 -0
  70. package/dist/src/iterableWeak.d.ts +53 -0
  71. package/dist/src/iterableWeak.d.ts.map +1 -0
  72. package/dist/src/mixins.d.ts +25 -0
  73. package/dist/src/mixins.d.ts.map +1 -0
  74. package/dist/src/promiseChain.d.ts +20 -0
  75. package/dist/src/promiseChain.d.ts.map +1 -0
  76. package/dist/src/reactive/array.d.ts +48 -0
  77. package/dist/src/reactive/array.d.ts.map +1 -0
  78. package/dist/src/reactive/buffer.d.ts +120 -0
  79. package/dist/src/reactive/buffer.d.ts.map +1 -0
  80. package/dist/src/reactive/change.d.ts +29 -0
  81. package/dist/src/reactive/change.d.ts.map +1 -0
  82. package/dist/src/reactive/deep-touch.d.ts +28 -0
  83. package/dist/src/reactive/deep-touch.d.ts.map +1 -0
  84. package/dist/src/reactive/deep-watch-state.d.ts +25 -0
  85. package/dist/src/reactive/deep-watch-state.d.ts.map +1 -0
  86. package/dist/src/reactive/deep-watch.d.ts +20 -0
  87. package/dist/src/reactive/deep-watch.d.ts.map +1 -0
  88. package/dist/src/reactive/describe.d.ts +12 -0
  89. package/dist/src/reactive/describe.d.ts.map +1 -0
  90. package/dist/src/reactive/effect-context.d.ts +34 -0
  91. package/dist/src/reactive/effect-context.d.ts.map +1 -0
  92. package/dist/src/reactive/effects.d.ts +164 -0
  93. package/dist/src/reactive/effects.d.ts.map +1 -0
  94. package/dist/src/reactive/index.d.ts +19 -0
  95. package/dist/src/reactive/index.d.ts.map +1 -0
  96. package/dist/src/reactive/map.d.ts +28 -0
  97. package/dist/src/reactive/map.d.ts.map +1 -0
  98. package/dist/src/reactive/memoize.d.ts +28 -0
  99. package/dist/src/reactive/memoize.d.ts.map +1 -0
  100. package/dist/src/reactive/non-reactive-state.d.ts +9 -0
  101. package/dist/src/reactive/non-reactive-state.d.ts.map +1 -0
  102. package/dist/src/reactive/non-reactive.d.ts +11 -0
  103. package/dist/src/reactive/non-reactive.d.ts.map +1 -0
  104. package/dist/src/reactive/project.d.ts +40 -0
  105. package/dist/src/reactive/project.d.ts.map +1 -0
  106. package/dist/src/reactive/proxy-state.d.ts +8 -0
  107. package/dist/src/reactive/proxy-state.d.ts.map +1 -0
  108. package/dist/src/reactive/proxy.d.ts +23 -0
  109. package/dist/src/reactive/proxy.d.ts.map +1 -0
  110. package/dist/src/reactive/record.d.ts +115 -0
  111. package/dist/src/reactive/record.d.ts.map +1 -0
  112. package/dist/src/reactive/register.d.ts +125 -0
  113. package/dist/src/reactive/register.d.ts.map +1 -0
  114. package/dist/src/reactive/registry.d.ts +21 -0
  115. package/dist/src/reactive/registry.d.ts.map +1 -0
  116. package/dist/src/reactive/set.d.ts +26 -0
  117. package/dist/src/reactive/set.d.ts.map +1 -0
  118. package/dist/src/reactive/tracking.d.ts +7 -0
  119. package/dist/src/reactive/tracking.d.ts.map +1 -0
  120. package/dist/src/reactive/types.d.ts +424 -0
  121. package/dist/src/reactive/types.d.ts.map +1 -0
  122. package/dist/src/reactive/watch.d.ts +48 -0
  123. package/dist/src/reactive/watch.d.ts.map +1 -0
  124. package/dist/src/std-decorators.d.ts +45 -0
  125. package/dist/src/std-decorators.d.ts.map +1 -0
  126. package/dist/src/utils.d.ts +49 -0
  127. package/dist/src/utils.d.ts.map +1 -0
  128. package/dist/src/zone.d.ts +40 -0
  129. package/dist/src/zone.d.ts.map +1 -0
  130. package/dist/types-DaHFfhlN.d.ts +414 -0
  131. package/docs/ai/api-reference.md +1 -1
  132. package/docs/ai/manual.md +443 -17
  133. package/docs/events.md +57 -2
  134. package/docs/flavored.md +210 -0
  135. package/docs/reactive/advanced.md +2 -3
  136. package/docs/reactive/attend.md +143 -0
  137. package/docs/reactive/collections.md +55 -0
  138. package/docs/reactive/core.md +120 -4
  139. package/docs/reactive/debugging.md +8 -10
  140. package/docs/reactive/describe.md +85 -0
  141. package/docs/reactive/error-handling.md +121 -0
  142. package/docs/reactive/project.md +1 -1
  143. package/docs/reactive/scan.md +215 -0
  144. package/docs/reactive.md +2 -0
  145. package/docs/std-decorators.md +171 -52
  146. package/docs/zone.md +9 -6
  147. package/package.json +44 -27
  148. package/src/async/browser.ts +277 -41
  149. package/src/async/index.ts +23 -4
  150. package/src/async/node.ts +78 -32
  151. package/src/entry-browser.ts +5 -0
  152. package/src/entry-node.ts +5 -0
  153. package/src/eventful.ts +82 -49
  154. package/src/flavored.ts +106 -0
  155. package/src/index.d.ts +12 -9
  156. package/src/index.ts +2 -1
  157. package/src/introspection.ts +1 -1
  158. package/src/reactive/array.ts +160 -80
  159. package/src/reactive/buffer.ts +187 -27
  160. package/src/reactive/change.ts +16 -23
  161. package/src/reactive/deep-touch.ts +19 -14
  162. package/src/reactive/deep-watch-state.ts +3 -3
  163. package/src/reactive/deep-watch.ts +7 -4
  164. package/src/reactive/describe.ts +39 -0
  165. package/src/reactive/effect-context.ts +71 -6
  166. package/src/reactive/effects.ts +445 -393
  167. package/src/reactive/index.ts +12 -14
  168. package/src/reactive/map.ts +8 -24
  169. package/src/reactive/memoize.ts +26 -3
  170. package/src/reactive/project.md +107 -0
  171. package/src/reactive/project.ts +48 -71
  172. package/src/reactive/proxy.ts +21 -3
  173. package/src/reactive/record.ts +30 -45
  174. package/src/reactive/register.ts +138 -19
  175. package/src/reactive/registry.ts +22 -9
  176. package/src/reactive/set.ts +7 -20
  177. package/src/reactive/tracking.ts +9 -12
  178. package/src/reactive/types.ts +104 -24
  179. package/src/reactive/{interface.ts → watch.ts} +29 -59
  180. package/src/std-decorators.ts +56 -17
  181. package/src/utils.ts +3 -4
  182. package/src/zone.ts +43 -28
  183. package/dist/browser.js +0 -161
  184. package/dist/browser.js.map +0 -1
  185. package/dist/chunks/index-BFYK02LG.js +0 -6378
  186. package/dist/chunks/index-BFYK02LG.js.map +0 -1
  187. package/dist/chunks/index-CNR6QRUl.esm.js +0 -6291
  188. package/dist/chunks/index-CNR6QRUl.esm.js.map +0 -1
  189. package/dist/node.js +0 -136
  190. package/dist/node.js.map +0 -1
  191. package/src/reactive/debug.ts +0 -517
@@ -1,9 +1,90 @@
1
1
  import { FoolProof } from '../utils'
2
+ import { cleanedBy } from '.'
2
3
  import { effect, untracked } from './effects'
3
- import { cleanedBy, cleanup } from './interface'
4
4
  import { memoize } from './memoize'
5
5
  import { reactive } from './proxy'
6
- import type { ScopedCallback } from './types'
6
+ import type { cleanup, EffectAccess, EffectCleanup, EffectCloser, ScopedCallback } from './types'
7
+
8
+ /**
9
+ * Reactively attends to each entry of a collection or each key yielded by an
10
+ * enumeration callback. For each key, an inner effect runs the callback. When a
11
+ * key disappears, its inner effect is disposed. The callback may return a cleanup
12
+ * (like a regular effect closer).
13
+ *
14
+ * Accepts arrays, records, Maps, Sets, or a raw `() => Iterable<Key>` callback.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * // Record shorthand
19
+ * attend(record, (key) => { console.log(key, record[key]) })
20
+ *
21
+ * // Array shorthand
22
+ * attend(array, (index) => { console.log(index, array[index]) })
23
+ *
24
+ * // Raw enumeration callback
25
+ * attend(() => Object.keys(record), (key) => { ... })
26
+ * ```
27
+ */
28
+ export function attend<T>(
29
+ source: readonly T[],
30
+ callback: (index: number) => EffectCloser | void
31
+ ): ScopedCallback
32
+ export function attend<K, V>(
33
+ source: Map<K, V>,
34
+ callback: (key: K) => EffectCloser | void
35
+ ): ScopedCallback
36
+ export function attend<T>(
37
+ source: Set<T>,
38
+ callback: (value: T) => EffectCloser | void
39
+ ): ScopedCallback
40
+ export function attend<S extends Record<PropertyKey, any>>(
41
+ source: S,
42
+ callback: (key: keyof S & string) => EffectCloser | void
43
+ ): ScopedCallback
44
+ export function attend<Key>(
45
+ enumerate: () => Iterable<Key>,
46
+ callback: (key: Key) => EffectCloser | void
47
+ ): ScopedCallback
48
+ export function attend(
49
+ source: any,
50
+ callback: (key: any) => EffectCloser | void
51
+ ): ScopedCallback {
52
+ const enumerate: () => Iterable<any> =
53
+ typeof source === 'function'
54
+ ? source
55
+ : Array.isArray(source)
56
+ ? () => Array.from({ length: source.length }, (_, i) => i)
57
+ : source instanceof Map
58
+ ? () => source.keys()
59
+ : source instanceof Set
60
+ ? () => source.values()
61
+ : () => Object.keys(source)
62
+
63
+ const keyEffects = new Map<any, ScopedCallback>()
64
+
65
+ const outer = effect(({ ascend }) => {
66
+ const keys = new Set<any>()
67
+ for (const key of enumerate()) keys.add(key)
68
+
69
+ for (const key of keys) {
70
+ if (keyEffects.has(key)) continue
71
+ keyEffects.set(key, ascend(() => effect(() => callback(key))))
72
+ }
73
+
74
+ for (const key of Array.from(keyEffects.keys())) {
75
+ if (!keys.has(key)) {
76
+ keyEffects.get(key)!()
77
+ keyEffects.delete(key)
78
+ }
79
+ }
80
+ })
81
+
82
+ return () => {
83
+ outer()
84
+ for (const stop of keyEffects.values()) stop()
85
+ keyEffects.clear()
86
+ }
87
+ }
7
88
 
8
89
  /**
9
90
  * Result of a reactive scan, which is a reactive array of accumulated values
@@ -15,22 +96,22 @@ export type ScanResult<Output> = readonly Output[] & { [cleanup]: ScopedCallback
15
96
  * Perform a reactive scan over an array of items.
16
97
  *
17
98
  * This implementation is highly optimized for performance and fine-grained reactivity:
18
- * - **Incremental Updates**: Changes to an item only trigger re-computation from that
99
+ * - **Incremental Updates**: Changes to an item only trigger re-computation from that
19
100
  * point onwards in the result chain.
20
- * - **Move Optimization**: If items are moved within the array, their accumulated
101
+ * - **Move Optimization**: If items are moved within the array, their accumulated
21
102
  * values are reused as long as their predecessor remains the same.
22
- * - **Duplicate Support**: Correctly handles multiple occurrences of the same object
103
+ * - **Duplicate Support**: Correctly handles multiple occurrences of the same object
23
104
  * instance using an internal occurrence tracking mechanism.
24
- * - **Memory Efficient**: Uses `WeakMap` for caching intermediates, which are
105
+ * - **Memory Efficient**: Uses `WeakMap` for caching intermediates, which are
25
106
  * automatically cleared when source items are garbage collected.
26
107
  *
27
108
  * @example
28
109
  * ```typescript
29
110
  * const source = reactive([{ val: 1 }, { val: 2 }, { val: 3 }])
30
111
  * const sum = scan(source, (acc, item) => acc + item.val, 0)
31
- *
112
+ *
32
113
  * expect([...sum]).toEqual([1, 3, 6])
33
- *
114
+ *
34
115
  * // Modifying an item only re-computes subsequent sums
35
116
  * source[1].val = 10
36
117
  * expect([...sum]).toEqual([1, 11, 14])
@@ -48,16 +129,19 @@ export function scan<Input extends object, Output>(
48
129
  ): ScanResult<Output> {
49
130
  const observedSource = reactive(source)
50
131
  const result = reactive([] as Output[])
51
-
132
+
52
133
  // Track effects for each index to dispose them when the array shrinks
53
- const indexEffects = new Map<number, ScopedCallback>()
134
+ const indexEffects = new Map<number, EffectCleanup>()
54
135
  // Mapping from index to its current intermediate object
55
136
  const indexToIntermediate = reactive([] as Intermediate[])
56
137
  const intermediaries = new WeakMap<Input, Intermediate[]>()
57
138
 
58
139
  class Intermediate {
59
140
  public prev: Intermediate | undefined
60
- constructor(public val: Input, prev: Intermediate | undefined) {
141
+ constructor(
142
+ public val: Input,
143
+ prev: Intermediate | undefined
144
+ ) {
61
145
  this.prev = prev
62
146
  }
63
147
 
@@ -83,12 +167,14 @@ export function scan<Input extends object, Output>(
83
167
  const mainEffect = effect(function scanMainEffect({ ascend }) {
84
168
  const length = observedSource.length
85
169
  const occurrenceCount = new Map<Input, number>()
86
- let prev: Intermediate | undefined = undefined
170
+ let prev: Intermediate | undefined
87
171
 
88
172
  for (let i = 0; i < length; i++) {
89
173
  const val = FoolProof.get(observedSource as any, i, observedSource) as Input
90
-
91
- if (!(val && (typeof val === 'object' || typeof val === 'function' || typeof val === 'symbol'))) {
174
+
175
+ if (
176
+ !(val && (typeof val === 'object' || typeof val === 'function' || typeof val === 'symbol'))
177
+ ) {
92
178
  throw new Error('scan: items must be objects (WeakKey) for intermediate caching')
93
179
  }
94
180
 
@@ -106,7 +192,7 @@ export function scan<Input extends object, Output>(
106
192
  intermediate = reactive(new Intermediate(val, prev))
107
193
  list[count] = intermediate
108
194
  } else {
109
- // Update the link.
195
+ // Update the link.
110
196
  if (untracked(() => intermediate.prev) !== prev) {
111
197
  intermediate.prev = prev
112
198
  }
@@ -141,7 +227,7 @@ export function scan<Input extends object, Output>(
141
227
  for (const index of Array.from(indexEffects.keys())) {
142
228
  if (index >= length) disposeIndex(index)
143
229
  }
144
-
230
+
145
231
  // Ensure result length matches source length
146
232
  untracked(() => {
147
233
  if (result.length !== length) {
@@ -150,19 +236,93 @@ export function scan<Input extends object, Output>(
150
236
  })
151
237
  })
152
238
 
153
- return cleanedBy(result, () => {
239
+ return cleanedBy(result, (() => {
154
240
  mainEffect()
155
241
  for (const stop of indexEffects.values()) stop()
156
242
  indexEffects.clear()
157
- }) as ScanResult<Output>
243
+ }) as EffectCleanup) as ScanResult<Output>
158
244
  }
159
245
 
160
- export function resolve<Output>(cb: () => Output[]): Output[] & { [cleanup]: ScopedCallback } {
161
- const result = reactive([] as Output[])
162
- return cleanedBy(result, effect(() => {
163
- const source = cb()
164
- if (result.length !== source.length) result.length = source.length
165
- for (let i = 0; i < source.length; i++)
166
- if (result[i] !== source[i]) result[i] = source[i]
167
- }))
168
- }
246
+ /**
247
+ * Lifts a callback that returns an array into a reactive array that automatically
248
+ * synchronizes with the source array returned by the callback.
249
+ *
250
+ * The returned reactive array will update whenever the callback's dependencies change,
251
+ * efficiently syncing only the elements that differ from the previous result.
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * const items = reactive([1, 2, 3])
256
+ * const doubled = lift(() => items.map(x => x * 2))
257
+ *
258
+ * console.log([...doubled]) // [2, 4, 6]
259
+ *
260
+ * items.push(4)
261
+ * console.log([...doubled]) // [2, 4, 6, 8]
262
+ * ```
263
+ *
264
+ * @param cb Callback function that returns an array
265
+ * @returns A reactive array synchronized with the callback's result, with a [cleanup] property to stop tracking
266
+ */
267
+ export function lift<Output extends any[]>(
268
+ cb: (access: EffectAccess) => Output
269
+ ): Output & { [cleanup]: ScopedCallback }
270
+
271
+ /**
272
+ * Lifts a callback that returns an object into a reactive object that automatically
273
+ * synchronizes with the source object returned by the callback.
274
+ *
275
+ * The returned reactive object will update whenever the callback's dependencies change,
276
+ * efficiently syncing only the properties that differ from the previous result using
277
+ * Object.assign(). Properties that no longer exist in the source are automatically removed.
278
+ *
279
+ * @example
280
+ * ```typescript
281
+ * const user = reactive({ name: 'John', age: 30 })
282
+ * const profile = lift(() => ({
283
+ * displayName: user.name.toUpperCase(),
284
+ * isAdult: user.age >= 18,
285
+ * description: `${user.name} is ${user.age} years old`
286
+ * }))
287
+ *
288
+ * console.log(profile.displayName) // JOHN
289
+ * console.log(profile.isAdult) // true
290
+ *
291
+ * user.name = 'Jane'
292
+ * console.log(profile.displayName) // JANE
293
+ * console.log(profile.description) // Jane is 30 years old
294
+ * ```
295
+ *
296
+ * @param cb Callback function that returns an object
297
+ * @returns A reactive object synchronized with the callback's result, with a [cleanup] property to stop tracking
298
+ */
299
+ export function lift<Output extends object>(
300
+ cb: (access: EffectAccess) => Output
301
+ ): Output & { [cleanup]: ScopedCallback }
302
+ export function lift<Output extends any[] | object>(
303
+ cb: (access: EffectAccess) => Output
304
+ ): Output & { [cleanup]: ScopedCallback } {
305
+ let result: Output
306
+ const liftCleanup = effect((access) => {
307
+ const source = cb(access)
308
+ if (!source || typeof source !== 'object')
309
+ throw new Error('lift callback must return an array or object')
310
+ if (!result) {
311
+ if (Array.isArray(source)) {
312
+ result = reactive([]) as Output
313
+ } else {
314
+ result = reactive({}) as Output
315
+ }
316
+ }
317
+ if (Array.isArray(source) && Array.isArray(result)) {
318
+ if (result.length !== source.length) result.length = source.length
319
+ for (let i = 0; i < source.length; i++) if (result[i] !== source[i]) result[i] = source[i]
320
+ } else if (!Array.isArray(source) && !Array.isArray(result)) {
321
+ Object.assign(result, source)
322
+ for (const key of Object.keys(result)) if (!(key in source)) delete result[key]
323
+ } else {
324
+ throw new Error('lift callback must return the same type as the previous result')
325
+ }
326
+ })
327
+ return cleanedBy(result as Output, liftCleanup)
328
+ }
@@ -1,10 +1,10 @@
1
- import { recordTriggerLink } from './debug'
1
+ import { recordTriggerLink } from '../../debug/debug'
2
2
  import { bubbleUpChange, objectsWithDeepWatchers } from './deep-watch-state'
3
3
  import { getActiveEffect, isRunning } from './effect-context'
4
- import { batch, effectTrackers, hasBatched, opaqueEffects, recordActivation } from './effects'
4
+ import { batch, hasBatched, recordActivation } from './effects'
5
5
  import { unwrap } from './proxy-state'
6
- import { watchers } from './registry'
7
- import { allProps, type Evolution, options, type ScopedCallback, type State } from './types'
6
+ import { getEffectNode, watchers } from './registry'
7
+ import { allProps, type EffectTrigger, type Evolution, optionCall, type State } from './types'
8
8
 
9
9
  const states = new WeakMap<object, State>()
10
10
 
@@ -34,31 +34,28 @@ export function getState(obj: any) {
34
34
  export function collectEffects(
35
35
  obj: any,
36
36
  evolution: Evolution,
37
- effects: Set<ScopedCallback>,
38
- objectWatchers: Map<any, Set<ScopedCallback>>,
37
+ effects: Set<EffectTrigger>,
38
+ objectWatchers: Map<any, Set<EffectTrigger>>,
39
39
  ...keyChains: Iterable<any>[]
40
40
  ) {
41
41
  const sourceEffect = getActiveEffect()
42
42
  for (const keys of keyChains)
43
43
  for (const key of keys) {
44
44
  const deps = objectWatchers.get(key)
45
+ // console.log(`[DEBUG] collectEffects: checking ${String(key)}. Found deps: ${deps ? deps.size : 'none'}`)
45
46
  if (deps)
46
47
  for (const effect of deps) {
48
+ // console.log(`[DEBUG] collectEffects: found dependency ${effect.name || 'anonymous'} for ${String(key)}`)
47
49
  const runningChain = isRunning(effect)
48
50
  if (runningChain) {
49
- options.skipRunningEffect(effect)
51
+ optionCall('skipRunningEffect', effect)
50
52
  continue
51
53
  }
52
54
  if (!effects.has(effect)) {
53
55
  effects.add(effect)
54
56
  if (!hasBatched(effect)) recordActivation(effect, obj, evolution, key)
55
57
  }
56
- const trackers = effectTrackers.get(effect)
57
58
  recordTriggerLink(sourceEffect, effect, obj, key, evolution)
58
- if (trackers) {
59
- for (const tracker of trackers) tracker(obj, evolution, key)
60
- trackers.delete(effect)
61
- }
62
59
  }
63
60
  }
64
61
  }
@@ -85,10 +82,10 @@ export function touched(obj: any, evolution: Evolution, props?: Iterable<any>) {
85
82
  const objectWatchers = watchers.get(obj)
86
83
  if (objectWatchers) {
87
84
  // Note: we have to collect effects to remove duplicates in the specific case when no batch is running
88
- const effects = new Set<ScopedCallback>()
85
+ const effects = new Set<EffectTrigger>()
89
86
  if (props) collectEffects(obj, evolution, effects, objectWatchers, [allProps], props)
90
87
  else collectEffects(obj, evolution, effects, objectWatchers, objectWatchers.keys())
91
- options.touched(obj, evolution, props as any[] | undefined, effects)
88
+ optionCall('touched', obj, evolution, props as any[] | undefined, effects)
92
89
  batch(Array.from(effects))
93
90
  }
94
91
 
@@ -110,29 +107,25 @@ export function touchedOpaque(obj: any, evolution: Evolution, prop: any) {
110
107
  const deps = objectWatchers.get(prop)
111
108
  if (!deps) return
112
109
 
113
- const effects = new Set<ScopedCallback>()
110
+ const effects = new Set<EffectTrigger>()
114
111
  const sourceEffect = getActiveEffect()
115
112
 
116
113
  for (const effect of deps) {
117
- if (!opaqueEffects.has(effect)) continue
114
+ const node = getEffectNode(effect)
115
+ if (!node.isOpaque) continue
118
116
 
119
117
  const runningChain = isRunning(effect)
120
118
  if (runningChain) {
121
- options.skipRunningEffect(effect)
119
+ optionCall('skipRunningEffect', effect)
122
120
  continue
123
121
  }
124
122
  effects.add(effect)
125
123
  recordActivation(effect, obj, evolution, prop)
126
- const trackers = effectTrackers.get(effect)
127
124
  recordTriggerLink(sourceEffect, effect, obj, prop, evolution)
128
- if (trackers) {
129
- for (const tracker of trackers) tracker(obj, evolution, prop)
130
- trackers.delete(effect)
131
- }
132
125
  }
133
126
 
134
127
  if (effects.size > 0) {
135
- options.touched(obj, evolution, [prop], effects)
128
+ optionCall('touched', obj, evolution, [prop], effects)
136
129
  batch(Array.from(effects))
137
130
  }
138
131
  }
@@ -3,8 +3,8 @@ import { bubbleUpChange, objectsWithDeepWatchers } from './deep-watch-state'
3
3
  import { batch } from './effects'
4
4
  import { isNonReactive } from './non-reactive-state'
5
5
  import { unwrap } from './proxy-state'
6
- import { effectParent, watchers } from './registry'
7
- import { allProps, type Evolution, options, type ScopedCallback } from './types'
6
+ import { getEffectNode, watchers } from './registry'
7
+ import { allProps, type EffectCleanup, type EffectTrigger, type Evolution, optionCall, options } from './types'
8
8
 
9
9
  function isObject(value: any): value is object {
10
10
  return typeof value === 'object' && value !== null
@@ -196,31 +196,35 @@ function diffObjectProperties(
196
196
  /**
197
197
  * Checks if an effect or any of its ancestors is in the allowed set
198
198
  */
199
- function hasAncestorInSet(effect: ScopedCallback, allowedSet: Set<ScopedCallback>): boolean {
200
- let current: ScopedCallback | undefined = effect
201
- const visited = new WeakSet<ScopedCallback>()
199
+ function hasAncestorInSet(
200
+ effect: EffectTrigger | EffectCleanup,
201
+ allowedSet: Set<EffectTrigger | EffectCleanup>
202
+ ): boolean {
203
+ let current: EffectTrigger | EffectCleanup | undefined = effect
204
+ const visited = new WeakSet<EffectTrigger | EffectCleanup>()
202
205
  while (current && !visited.has(current)) {
203
206
  visited.add(current)
204
207
  if (allowedSet.has(current)) return true
205
- current = effectParent.get(current)
208
+ const node = getEffectNode(current as EffectTrigger)
209
+ current = node.parent
206
210
  }
207
211
  return false
208
212
  }
209
213
 
210
214
  export function dispatchNotifications(notifications: PendingNotification[]) {
211
215
  if (!notifications.length) return
212
- const combinedEffects = new Set<ScopedCallback>()
216
+ const combinedEffects = new Set<EffectTrigger>()
213
217
 
214
218
  // Extract origin from first notification (all should have the same origin from a single deep touch)
215
219
  const origin = notifications[0]?.origin
216
- let allowedEffects: Set<ScopedCallback> | undefined
220
+ let allowedEffects: Set<EffectTrigger> | undefined
217
221
 
218
222
  // If origin exists, compute allowed effects (those that depend on origin.obj[origin.prop])
219
223
  if (origin) {
220
- allowedEffects = new Set<ScopedCallback>()
224
+ allowedEffects = new Set<EffectTrigger>()
221
225
  const originWatchers = watchers.get(origin.obj)
222
226
  if (originWatchers) {
223
- const originEffects = new Set<ScopedCallback>()
227
+ const originEffects = new Set<EffectTrigger>()
224
228
  collectEffects(
225
229
  origin.obj,
226
230
  { type: 'set', prop: origin.prop },
@@ -240,16 +244,17 @@ export function dispatchNotifications(notifications: PendingNotification[]) {
240
244
  const obj = unwrap(target)
241
245
  addState(obj, evolution)
242
246
  const objectWatchers = watchers.get(obj)
243
- let currentEffects: Set<ScopedCallback> | undefined
247
+ let currentEffects: Set<EffectTrigger> | undefined
244
248
  const propsArray = [prop]
245
249
  if (objectWatchers) {
246
- currentEffects = new Set<ScopedCallback>()
250
+ // console.log(`[DEBUG] dispatchNotifications: processing ${obj.constructor.name} (has watchers)`)
251
+ currentEffects = new Set<EffectTrigger>()
247
252
  collectEffects(obj, evolution, currentEffects, objectWatchers, [allProps], propsArray)
248
253
 
249
254
  // Filter effects by ancestor chain if origin exists
250
255
  // Include effects that either directly depend on origin or have an ancestor that does
251
256
  if (origin && allowedEffects) {
252
- const filteredEffects = new Set<ScopedCallback>()
257
+ const filteredEffects = new Set<EffectTrigger>()
253
258
  for (const effect of currentEffects) {
254
259
  // Check if effect itself is allowed OR has an ancestor that is allowed
255
260
  if (allowedEffects.has(effect) || hasAncestorInSet(effect, allowedEffects)) {
@@ -261,7 +266,7 @@ export function dispatchNotifications(notifications: PendingNotification[]) {
261
266
 
262
267
  for (const effect of currentEffects) combinedEffects.add(effect)
263
268
  }
264
- options.touched(obj, evolution, propsArray, currentEffects)
269
+ optionCall('touched', obj, evolution, propsArray, currentEffects)
265
270
  if (objectsWithDeepWatchers.has(obj)) bubbleUpChange(obj, evolution)
266
271
  }
267
272
  if (combinedEffects.size) batch([...combinedEffects])
@@ -1,5 +1,5 @@
1
1
  import { batch } from './effects'
2
- import type { Evolution, ScopedCallback } from './types'
2
+ import type { EffectTrigger, Evolution } from './types'
3
3
 
4
4
  // Track which objects contain which other objects (back-references)
5
5
  export const objectParents = new WeakMap<object, Set<{ parent: object; prop: PropertyKey }>>()
@@ -8,10 +8,10 @@ export const objectParents = new WeakMap<object, Set<{ parent: object; prop: Pro
8
8
  export const objectsWithDeepWatchers = new WeakSet<object>()
9
9
 
10
10
  // Track deep watchers per object
11
- export const deepWatchers = new WeakMap<object, Set<ScopedCallback>>()
11
+ export const deepWatchers = new WeakMap<object, Set<EffectTrigger>>()
12
12
 
13
13
  // Track which effects are doing deep watching
14
- export const effectToDeepWatchedObjects = new WeakMap<ScopedCallback, Set<object>>()
14
+ export const effectToDeepWatchedObjects = new WeakMap<EffectTrigger, Set<object>>()
15
15
 
16
16
  /**
17
17
  * Add a back-reference from child to parent
@@ -7,7 +7,7 @@ import { effect } from './effects'
7
7
  import { isNonReactive } from './non-reactive-state'
8
8
  import { reactive, unwrap } from './proxy'
9
9
  import { markWithRoot } from './registry'
10
- import { options, type ScopedCallback } from './types'
10
+ import { type EffectCleanup, type EffectTrigger, options } from './types'
11
11
 
12
12
  function isObject(value: any): value is object {
13
13
  return typeof value === 'object' && value !== null
@@ -42,11 +42,14 @@ export function deepWatch<T extends object>(
42
42
  target: T,
43
43
  callback: (value: T) => void,
44
44
  { immediate = false } = {}
45
- ): (() => void) | undefined {
45
+ ): EffectCleanup | undefined {
46
46
  if (target === null || target === undefined) return undefined
47
47
  if (typeof target !== 'object') throw new Error('Target of deep watching must be an object')
48
- // Create a wrapper callback that matches ScopedCallback signature
49
- const wrappedCallback: ScopedCallback = markWithRoot(() => callback(target), callback)
48
+ // Create a wrapper callback that matches EffectTrigger signature
49
+ const wrappedCallback: EffectTrigger = markWithRoot(
50
+ (() => callback(target)) as EffectTrigger,
51
+ callback
52
+ )
50
53
 
51
54
  // Use the existing effect system to register dependencies
52
55
  return effect(() => {
@@ -0,0 +1,39 @@
1
+ import { attend } from './buffer'
2
+ import { touched1 } from './change'
3
+ import { cleanedBy } from './effect-context'
4
+ import { reactive } from './proxy'
5
+
6
+ /**
7
+ * Reactively defines properties on a target object based on a descriptors record.
8
+ *
9
+ * It tracks the keys of the descriptors record and defines/updates/removes
10
+ * properties on the target object as they change.
11
+ *
12
+ * @param descriptors - A reactive record of property descriptors.
13
+ * @param target - The object to define properties on (defaults to a new object).
14
+ * @returns The target object with reactive property definitions.
15
+ */
16
+ export function describe<T extends object>(
17
+ descriptors: Record<PropertyKey, PropertyDescriptor>,
18
+ target: T = Object.create(null) as T
19
+ ): T {
20
+ descriptors = reactive(descriptors)
21
+
22
+ const stop = attend(
23
+ () => Reflect.ownKeys(descriptors),
24
+ (key) => {
25
+ const desc = (descriptors as any)[key]
26
+ if (desc) {
27
+ Object.defineProperty(target, key, {
28
+ enumerable: true,
29
+ ...desc,
30
+ configurable: true,
31
+ })
32
+ touched1(target, { type: 'set', prop: key }, key)
33
+ }
34
+ return () => Reflect.deleteProperty(target, key)
35
+ }
36
+ )
37
+
38
+ return cleanedBy(target, stop)
39
+ }
@@ -1,18 +1,83 @@
1
1
  import { tag } from '../utils'
2
2
  import { asyncZone, ZoneAggregator, ZoneHistory } from '../zone'
3
+ import { effect, untracked } from './effects'
3
4
  import { getRoot } from './registry'
4
- import { type ScopedCallback } from './types'
5
+ import {
6
+ cleanup,
7
+ type EffectAccess,
8
+ type EffectTrigger,
9
+ type ScopedCallback,
10
+ stopped,
11
+ } from './types'
5
12
 
6
- export const effectHistory = tag(new ZoneHistory<ScopedCallback>(), 'effectHistory')
7
- tag(effectHistory.present, 'effectHistory.present')
13
+ export const effectHistory = tag('effectHistory', new ZoneHistory<EffectTrigger>())
14
+ tag('effectHistory.present', effectHistory.present)
8
15
  asyncZone.add(effectHistory)
9
- export const effectAggregator = tag(new ZoneAggregator(effectHistory.present), 'effectAggregator')
10
16
 
11
- export function isRunning(effect: ScopedCallback): boolean {
17
+ /**
18
+ * Aggregator for zones that need to be tracked along effects.
19
+ * ie. in each effect, the active zone of the given zoning will be the one active at effect's definition
20
+ */
21
+ export const effectAggregator = tag('effectAggregator', new ZoneAggregator(effectHistory.present))
22
+
23
+ export function isRunning(effect: EffectTrigger): boolean {
12
24
  const root = getRoot(effect)
13
25
  return effectHistory.some((e) => getRoot(e) === root)
14
26
  }
15
27
 
16
28
  export function getActiveEffect() {
17
29
  return effectHistory.present.active
18
- }
30
+ }
31
+
32
+ /**
33
+ * ADD a cleanup function to an object using the cleanup symbol.
34
+ * The cleanup function will be called when the object needs to be disposed.
35
+ *
36
+ * Note: most of the time, you don't need to use this function directly.
37
+ * The main use if for the cleanup function to be stored with the object, as GC calls the cleanup function when the *function* is garbage collected.
38
+ *
39
+ * @param obj - The object to attach the cleanup function to
40
+ * @param cleanupFn - The cleanup function to attach
41
+ * @returns The object with the cleanup function attached
42
+ */
43
+ export function cleanedBy<T extends object>(obj: T, cleanupFn: ScopedCallback) {
44
+ const oldCleanup = obj[cleanup]
45
+ return Object.defineProperty(obj, cleanup, {
46
+ value: oldCleanup
47
+ ? Object.defineProperties(
48
+ () => {
49
+ oldCleanup()
50
+ cleanupFn()
51
+ },
52
+ {
53
+ [stopped]: { get: () => oldCleanup[stopped] || cleanupFn[stopped] },
54
+ }
55
+ )
56
+ : cleanupFn,
57
+ writable: false,
58
+ enumerable: false,
59
+ configurable: true,
60
+ }) as T & { [cleanup]: ScopedCallback }
61
+ }
62
+
63
+ //#region greedy caching
64
+
65
+ /**
66
+ * Creates a derived value that automatically recomputes when dependencies change
67
+ * @param compute - Function that computes the derived value
68
+ * @returns Object with value and cleanup function
69
+ */
70
+ export function derived<T>(compute: (dep: EffectAccess) => T): {
71
+ value: T
72
+ [cleanup]: ScopedCallback
73
+ } {
74
+ const rv = { value: undefined as unknown as T }
75
+ return cleanedBy(
76
+ rv,
77
+ untracked(() =>
78
+ effect(function derivedEffect(access) {
79
+ rv.value = compute(access)
80
+ })
81
+ )
82
+ )
83
+ }