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,268 @@
1
+ import { addState, collectEffects, touched1, touchedOpaque } from './change'
2
+ import { bubbleUpChange, objectsWithDeepWatchers } from './deep-watch-state'
3
+ import { batch } from './effects'
4
+ import { isNonReactive } from './non-reactive-state'
5
+ import { unwrap } from './proxy-state'
6
+ import { effectParent, watchers } from './tracking'
7
+ import { allProps, type Evolution, options, type ScopedCallback } from './types'
8
+
9
+ function isObject(value: any): value is object {
10
+ return typeof value === 'object' && value !== null
11
+ }
12
+
13
+ function isObjectLike(value: unknown): value is object {
14
+ return isObject(value)
15
+ }
16
+
17
+ function getPrototypeToken(value: any): object | null | undefined {
18
+ if (!isObjectLike(value)) return undefined
19
+ if (Array.isArray(value)) return Array.prototype
20
+ try {
21
+ return value.constructor
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ export function shouldRecurseTouch(oldValue: any, newValue: any): boolean {
28
+ if (oldValue === newValue) return false
29
+ if (!isObjectLike(oldValue) || !isObjectLike(newValue)) return false
30
+ if (isNonReactive(oldValue) || isNonReactive(newValue)) return false
31
+ return getPrototypeToken(oldValue) === getPrototypeToken(newValue)
32
+ }
33
+
34
+ /**
35
+ * Centralized function to handle property change notifications with optional recursive touch
36
+ * @param targetObj - The object whose property changed
37
+ * @param prop - The property that changed
38
+ * @param oldValue - The old value (before change)
39
+ * @param newValue - The new value (after change)
40
+ * @param hadProperty - Whether the property existed before (for add vs set)
41
+ */
42
+ export function notifyPropertyChange(
43
+ targetObj: any,
44
+ prop: any,
45
+ oldValue: any,
46
+ newValue: any,
47
+ hadProperty: boolean
48
+ ) {
49
+ const evolution: Evolution = { type: hadProperty ? 'set' : 'add', prop }
50
+
51
+ if (
52
+ options.recursiveTouching &&
53
+ oldValue !== undefined &&
54
+ shouldRecurseTouch(oldValue, newValue)
55
+ ) {
56
+ const unwrappedObj = unwrap(targetObj)
57
+ const origin = { obj: unwrappedObj, prop }
58
+ // Deep touch: only notify nested property changes with origin filtering
59
+ // Don't notify direct property change - the whole point is to avoid parent effects re-running
60
+ dispatchNotifications(recursiveTouch(oldValue, newValue, new WeakMap(), [], origin))
61
+
62
+ // Notify opaque listeners (like memoize) that always want to know about identity changes
63
+ touchedOpaque(targetObj, evolution, prop)
64
+ } else {
65
+ touched1(targetObj, evolution, prop)
66
+ }
67
+ }
68
+
69
+ type VisitedPairs = WeakMap<object, WeakSet<object>>
70
+ type PendingNotification = {
71
+ target: any
72
+ evolution: Evolution
73
+ prop: any
74
+ origin?: { obj: object; prop: PropertyKey } // The property access that triggered this deep touch
75
+ }
76
+
77
+ function hasVisitedPair(visited: VisitedPairs, oldObj: object, newObj: object): boolean {
78
+ let mapped = visited.get(oldObj)
79
+ if (!mapped) {
80
+ mapped = new WeakSet<object>()
81
+ visited.set(oldObj, mapped)
82
+ }
83
+ if (mapped.has(newObj)) return true
84
+ mapped.add(newObj)
85
+ return false
86
+ }
87
+
88
+ function collectObjectKeys(obj: any): Set<PropertyKey> {
89
+ const keys = new Set<PropertyKey>(Reflect.ownKeys(obj))
90
+ let proto = Object.getPrototypeOf(obj)
91
+ // Continue walking while prototype exists and doesn't have its own constructor
92
+ // This stops at Object.prototype (has own constructor) and class prototypes (have own constructor)
93
+ // but continues for data prototypes (Object.create({}), Object.create(instance), etc.)
94
+ while (proto && !Object.hasOwn(proto, 'constructor')) {
95
+ for (const key of Reflect.ownKeys(proto)) keys.add(key)
96
+ proto = Object.getPrototypeOf(proto)
97
+ }
98
+ return keys
99
+ }
100
+
101
+ export function recursiveTouch(
102
+ oldValue: any,
103
+ newValue: any,
104
+ visited: VisitedPairs = new WeakMap(),
105
+ notifications: PendingNotification[] = [],
106
+ origin?: { obj: object; prop: PropertyKey }
107
+ ): PendingNotification[] {
108
+ if (!shouldRecurseTouch(oldValue, newValue)) return notifications
109
+ if (!isObjectLike(oldValue) || !isObjectLike(newValue)) return notifications
110
+ if (hasVisitedPair(visited, oldValue, newValue)) return notifications
111
+
112
+ if (Array.isArray(oldValue) && Array.isArray(newValue)) {
113
+ diffArrayElements(oldValue, newValue, visited, notifications, origin)
114
+ return notifications
115
+ }
116
+
117
+ diffObjectProperties(oldValue, newValue, visited, notifications, origin)
118
+ return notifications
119
+ }
120
+
121
+ function diffArrayElements(
122
+ oldArray: any[] | readonly any[],
123
+ newArray: any[] | readonly any[],
124
+ _visited: VisitedPairs,
125
+ notifications: PendingNotification[],
126
+ origin?: { obj: object; prop: PropertyKey }
127
+ ) {
128
+ const local: PendingNotification[] = []
129
+ const oldLength = oldArray.length
130
+ const newLength = newArray.length
131
+ const max = Math.max(oldLength, newLength)
132
+
133
+ for (let index = 0; index < max; index++) {
134
+ const hasOld = index < oldLength
135
+ const hasNew = index < newLength
136
+ if (hasOld && !hasNew) {
137
+ local.push({ target: oldArray, evolution: { type: 'del', prop: index }, prop: index, origin })
138
+ continue
139
+ }
140
+ if (!hasOld && hasNew) {
141
+ local.push({ target: oldArray, evolution: { type: 'add', prop: index }, prop: index, origin })
142
+ continue
143
+ }
144
+ if (!hasOld || !hasNew) continue
145
+ const oldEntry = unwrap(oldArray[index])
146
+ const newEntry = unwrap(newArray[index])
147
+ if (!Object.is(oldEntry, newEntry)) {
148
+ local.push({ target: oldArray, evolution: { type: 'set', prop: index }, prop: index, origin })
149
+ }
150
+ }
151
+
152
+ if (oldLength !== newLength)
153
+ local.push({
154
+ target: oldArray,
155
+ evolution: { type: 'set', prop: 'length' },
156
+ prop: 'length',
157
+ origin,
158
+ })
159
+
160
+ notifications.push(...local)
161
+ }
162
+
163
+ function diffObjectProperties(
164
+ oldObj: any,
165
+ newObj: any,
166
+ visited: VisitedPairs,
167
+ notifications: PendingNotification[],
168
+ origin?: { obj: object; prop: PropertyKey }
169
+ ) {
170
+ const oldKeys = collectObjectKeys(oldObj)
171
+ const newKeys = collectObjectKeys(newObj)
172
+ const local: PendingNotification[] = []
173
+
174
+ for (const key of oldKeys)
175
+ if (!newKeys.has(key))
176
+ local.push({ target: oldObj, evolution: { type: 'del', prop: key }, prop: key, origin })
177
+
178
+ for (const key of newKeys)
179
+ if (!oldKeys.has(key))
180
+ local.push({ target: oldObj, evolution: { type: 'add', prop: key }, prop: key, origin })
181
+
182
+ for (const key of newKeys) {
183
+ if (!oldKeys.has(key)) continue
184
+ const oldEntry = unwrap((oldObj as any)[key])
185
+ const newEntry = unwrap((newObj as any)[key])
186
+ if (shouldRecurseTouch(oldEntry, newEntry)) {
187
+ recursiveTouch(oldEntry, newEntry, visited, notifications, origin)
188
+ } else if (!Object.is(oldEntry, newEntry)) {
189
+ local.push({ target: oldObj, evolution: { type: 'set', prop: key }, prop: key, origin })
190
+ }
191
+ }
192
+
193
+ notifications.push(...local)
194
+ }
195
+
196
+ /**
197
+ * Checks if an effect or any of its ancestors is in the allowed set
198
+ */
199
+ function hasAncestorInSet(effect: ScopedCallback, allowedSet: Set<ScopedCallback>): boolean {
200
+ let current: ScopedCallback | undefined = effect
201
+ const visited = new WeakSet<ScopedCallback>()
202
+ while (current && !visited.has(current)) {
203
+ visited.add(current)
204
+ if (allowedSet.has(current)) return true
205
+ current = effectParent.get(current)
206
+ }
207
+ return false
208
+ }
209
+
210
+ export function dispatchNotifications(notifications: PendingNotification[]) {
211
+ if (!notifications.length) return
212
+ const combinedEffects = new Set<ScopedCallback>()
213
+
214
+ // Extract origin from first notification (all should have the same origin from a single deep touch)
215
+ const origin = notifications[0]?.origin
216
+ let allowedEffects: Set<ScopedCallback> | undefined
217
+
218
+ // If origin exists, compute allowed effects (those that depend on origin.obj[origin.prop])
219
+ if (origin) {
220
+ allowedEffects = new Set<ScopedCallback>()
221
+ const originWatchers = watchers.get(origin.obj)
222
+ if (originWatchers) {
223
+ const originEffects = new Set<ScopedCallback>()
224
+ collectEffects(
225
+ origin.obj,
226
+ { type: 'set', prop: origin.prop },
227
+ originEffects,
228
+ originWatchers,
229
+ [allProps],
230
+ [origin.prop]
231
+ )
232
+ for (const effect of originEffects) allowedEffects.add(effect)
233
+ }
234
+ // If no allowed effects, skip all notifications (no one should be notified)
235
+ if (allowedEffects.size === 0) return
236
+ }
237
+
238
+ for (const { target, evolution, prop } of notifications) {
239
+ if (!isObjectLike(target)) continue
240
+ const obj = unwrap(target)
241
+ addState(obj, evolution)
242
+ const objectWatchers = watchers.get(obj)
243
+ let currentEffects: Set<ScopedCallback> | undefined
244
+ const propsArray = [prop]
245
+ if (objectWatchers) {
246
+ currentEffects = new Set<ScopedCallback>()
247
+ collectEffects(obj, evolution, currentEffects, objectWatchers, [allProps], propsArray)
248
+
249
+ // Filter effects by ancestor chain if origin exists
250
+ // Include effects that either directly depend on origin or have an ancestor that does
251
+ if (origin && allowedEffects) {
252
+ const filteredEffects = new Set<ScopedCallback>()
253
+ for (const effect of currentEffects) {
254
+ // Check if effect itself is allowed OR has an ancestor that is allowed
255
+ if (allowedEffects.has(effect) || hasAncestorInSet(effect, allowedEffects)) {
256
+ filteredEffects.add(effect)
257
+ }
258
+ }
259
+ currentEffects = filteredEffects
260
+ }
261
+
262
+ for (const effect of currentEffects) combinedEffects.add(effect)
263
+ }
264
+ options.touched(obj, evolution, propsArray, currentEffects)
265
+ if (objectsWithDeepWatchers.has(obj)) bubbleUpChange(obj, evolution)
266
+ }
267
+ if (combinedEffects.size) batch([...combinedEffects])
268
+ }
@@ -0,0 +1,82 @@
1
+ import { batch } from './effects'
2
+ import type { Evolution, ScopedCallback } from './types'
3
+
4
+ // Track which objects contain which other objects (back-references)
5
+ export const objectParents = new WeakMap<object, Set<{ parent: object; prop: PropertyKey }>>()
6
+
7
+ // Track which objects have deep watchers
8
+ export const objectsWithDeepWatchers = new WeakSet<object>()
9
+
10
+ // Track deep watchers per object
11
+ export const deepWatchers = new WeakMap<object, Set<ScopedCallback>>()
12
+
13
+ // Track which effects are doing deep watching
14
+ export const effectToDeepWatchedObjects = new WeakMap<ScopedCallback, Set<object>>()
15
+
16
+ /**
17
+ * Add a back-reference from child to parent
18
+ */
19
+ export function addBackReference(child: object, parent: object, prop: any) {
20
+ let parents = objectParents.get(child)
21
+ if (!parents) {
22
+ parents = new Set()
23
+ objectParents.set(child, parents)
24
+ }
25
+ parents.add({ parent, prop })
26
+ }
27
+
28
+ /**
29
+ * Remove a back-reference from child to parent
30
+ */
31
+ export function removeBackReference(child: object, parent: object, prop: any) {
32
+ const parents = objectParents.get(child)
33
+ if (parents) {
34
+ for (const entry of parents) {
35
+ if (entry.parent === parent && entry.prop === prop) {
36
+ parents.delete(entry)
37
+ break
38
+ }
39
+ }
40
+ if (parents.size === 0) {
41
+ objectParents.delete(child)
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Check if an object needs back-references (has deep watchers or parents with deep watchers)
48
+ */
49
+ export function needsBackReferences(obj: object): boolean {
50
+ // Fast path: check if object itself has deep watchers
51
+ if (objectsWithDeepWatchers.has(obj)) return true
52
+ // Slow path: check if any parent has deep watchers (recursive)
53
+ return hasParentWithDeepWatchers(obj)
54
+ }
55
+
56
+ /**
57
+ * Bubble up changes through the back-reference chain
58
+ */
59
+ export function bubbleUpChange(changedObject: object, evolution: Evolution) {
60
+ const parents = objectParents.get(changedObject)
61
+ if (!parents) return
62
+
63
+ for (const { parent } of parents) {
64
+ // Trigger deep watchers on parent
65
+ const parentDeepWatchers = deepWatchers.get(parent)
66
+ if (parentDeepWatchers) for (const watcher of parentDeepWatchers) batch(watcher)
67
+
68
+ // Continue bubbling up
69
+ bubbleUpChange(parent, evolution)
70
+ }
71
+ }
72
+
73
+ function hasParentWithDeepWatchers(obj: object): boolean {
74
+ const parents = objectParents.get(obj)
75
+ if (!parents) return false
76
+
77
+ for (const { parent } of parents) {
78
+ if (objectsWithDeepWatchers.has(parent)) return true
79
+ if (hasParentWithDeepWatchers(parent)) return true
80
+ }
81
+ return false
82
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ deepWatchers,
3
+ effectToDeepWatchedObjects,
4
+ objectsWithDeepWatchers,
5
+ } from './deep-watch-state'
6
+ import { effect } from './effects'
7
+ import { isNonReactive } from './non-reactive-state'
8
+ import { reactive, unwrap } from './proxy'
9
+ import { markWithRoot } from './tracking'
10
+ import { options, type ScopedCallback } from './types'
11
+
12
+ function isObject(value: any): value is object {
13
+ return typeof value === 'object' && value !== null
14
+ }
15
+
16
+ export {
17
+ addBackReference,
18
+ bubbleUpChange,
19
+ deepWatchers,
20
+ effectToDeepWatchedObjects,
21
+ needsBackReferences,
22
+ objectParents,
23
+ objectsWithDeepWatchers,
24
+ removeBackReference,
25
+ } from './deep-watch-state'
26
+
27
+ /**
28
+ * Deep watch an object and all its nested properties
29
+ * @param target - The object to watch deeply
30
+ * @param callback - The callback to call when any nested property changes
31
+ * @param options - Options for the deep watch
32
+ * @returns A cleanup function to stop watching
33
+ */
34
+ /**
35
+ * Sets up deep watching for an object, tracking all nested property changes
36
+ * @param target - The object to watch
37
+ * @param callback - The callback to call when changes occur
38
+ * @param options - Options for deep watching
39
+ * @returns A cleanup function to stop deep watching
40
+ */
41
+ export function deepWatch<T extends object>(
42
+ target: T,
43
+ callback: (value: T) => void,
44
+ { immediate = false } = {}
45
+ ): (() => void) | undefined {
46
+ if (target === null || target === undefined) return undefined
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)
50
+
51
+ // Use the existing effect system to register dependencies
52
+ return effect(() => {
53
+ // Mark the target object as having deep watchers
54
+ objectsWithDeepWatchers.add(target)
55
+
56
+ // Track which objects this effect is watching for cleanup
57
+ let effectObjects = effectToDeepWatchedObjects.get(wrappedCallback)
58
+ if (!effectObjects) {
59
+ effectObjects = new Set()
60
+ effectToDeepWatchedObjects.set(wrappedCallback, effectObjects)
61
+ }
62
+ effectObjects!.add(target)
63
+
64
+ // Traverse the object graph and register dependencies
65
+ // This will re-run every time the effect runs, ensuring we catch all changes
66
+ const visited = new WeakSet()
67
+ function traverseAndTrack(obj: any, depth = 0) {
68
+ // Prevent infinite recursion and excessive depth
69
+ if (!obj || visited.has(obj) || !isObject(obj) || depth > options.maxDeepWatchDepth) return
70
+ // Do not traverse into unreactive objects
71
+ if (isNonReactive(obj)) return
72
+ visited.add(obj)
73
+
74
+ // Mark this object as having deep watchers
75
+ objectsWithDeepWatchers.add(obj)
76
+ effectObjects!.add(obj)
77
+
78
+ // Traverse all properties to register dependencies
79
+ // unwrap to avoid kicking dependency
80
+ for (const key in unwrap(obj)) {
81
+ if (Object.hasOwn(obj, key)) {
82
+ // Access the property to register dependency
83
+ const value = (obj as any)[key]
84
+ // Make the value reactive if it's an object
85
+ const reactiveValue =
86
+ typeof value === 'object' && value !== null ? reactive(value) : value
87
+ traverseAndTrack(reactiveValue, depth + 1)
88
+ }
89
+ }
90
+
91
+ // Also handle array indices and length
92
+ // biome-ignore lint/suspicious/useIsArray: Check for both native arrays and reactive arrays
93
+ if (Array.isArray(obj) || obj instanceof Array) {
94
+ // Access array length to register dependency on length changes
95
+ const length = obj.length
96
+
97
+ // Access all current array elements to register dependencies
98
+ for (let i = 0; i < length; i++) {
99
+ // Access the array element to register dependency
100
+ const value = obj[i]
101
+ // Make the value reactive if it's an object
102
+ const reactiveValue =
103
+ typeof value === 'object' && value !== null ? reactive(value) : value
104
+ traverseAndTrack(reactiveValue, depth + 1)
105
+ }
106
+ }
107
+ // Handle Set values (deep watch values only, not keys since Sets don't have separate keys)
108
+ else if (obj instanceof Set) {
109
+ // Access all Set values to register dependencies
110
+ for (const value of obj) {
111
+ // Make the value reactive if it's an object
112
+ const reactiveValue =
113
+ typeof value === 'object' && value !== null ? reactive(value) : value
114
+ traverseAndTrack(reactiveValue, depth + 1)
115
+ }
116
+ }
117
+ // Handle Map values (deep watch values only, not keys)
118
+ else if (obj instanceof Map) {
119
+ // Access all Map values to register dependencies
120
+ for (const [_key, value] of obj) {
121
+ // Make the value reactive if it's an object
122
+ const reactiveValue =
123
+ typeof value === 'object' && value !== null ? reactive(value) : value
124
+ traverseAndTrack(reactiveValue, depth + 1)
125
+ }
126
+ }
127
+ // Note: WeakSet and WeakMap cannot be iterated, so we can't deep watch their contents
128
+ // They will only trigger when the collection itself is replaced
129
+ }
130
+
131
+ // Traverse the target object to register all dependencies
132
+ // This will register dependencies on all current properties and array elements
133
+ traverseAndTrack(target)
134
+
135
+ // Only call the callback if immediate is true or if it's not the first run
136
+ if (immediate) callback(target)
137
+ immediate = true
138
+
139
+ // Return a cleanup function that properly removes deep watcher tracking
140
+ return () => {
141
+ // Get the objects this effect was watching
142
+ const effectObjects = effectToDeepWatchedObjects.get(wrappedCallback)
143
+ if (effectObjects) {
144
+ // Remove deep watcher tracking from all objects this effect was watching
145
+ for (const obj of effectObjects) {
146
+ // Check if this object still has other deep watchers
147
+ const watchers = deepWatchers.get(obj)
148
+ if (watchers) {
149
+ // Remove this effect's callback from the watchers
150
+ watchers.delete(wrappedCallback)
151
+
152
+ // If no more watchers, remove the object from deep watchers tracking
153
+ if (watchers.size === 0) {
154
+ deepWatchers.delete(obj)
155
+ objectsWithDeepWatchers.delete(obj)
156
+ }
157
+ } else {
158
+ // No watchers found, remove from deep watchers tracking
159
+ objectsWithDeepWatchers.delete(obj)
160
+ }
161
+ }
162
+
163
+ // Clean up the tracking data
164
+ effectToDeepWatchedObjects.delete(wrappedCallback)
165
+ }
166
+ }
167
+ })
168
+ }
@@ -0,0 +1,94 @@
1
+ import { effectParent, getRoot } from './tracking'
2
+ import { ReactiveError, type ScopedCallback } from './types'
3
+
4
+ /**
5
+ * Effect context stack for nested tracking (front = active, next = parent)
6
+ */
7
+ const stack: (ScopedCallback | undefined)[] = []
8
+ export const effectStack = stack
9
+
10
+ export function captureEffectStack() {
11
+ return stack.slice()
12
+ }
13
+ export function isRunning(effect: ScopedCallback): (ScopedCallback | undefined)[] | false {
14
+ const rootEffect = getRoot(effect)
15
+
16
+ // Check if the effect is directly in the stack
17
+ const rootIndex = stack.indexOf(rootEffect)
18
+ if (rootIndex !== -1) {
19
+ return stack.slice(0, rootIndex + 1).reverse()
20
+ }
21
+
22
+ // Check if any effect in the stack is a descendant of this effect
23
+ // (i.e., walk up the parent chain from each stack effect to see if we reach this effect)
24
+ for (let i = 0; i < stack.length; i++) {
25
+ const stackEffect = stack[i]
26
+ let current: ScopedCallback | undefined = stackEffect
27
+ const visited = new WeakSet<ScopedCallback>()
28
+ const ancestorChain: ScopedCallback[] = []
29
+ // TODO: That's perhaps a lot of computations for an `assert`
30
+ // Walk up the parent chain to find if this effect is an ancestor
31
+ while (current && !visited.has(current)) {
32
+ visited.add(current)
33
+ const currentRoot = getRoot(current)
34
+ ancestorChain.push(currentRoot)
35
+ if (currentRoot === rootEffect) {
36
+ // Found a descendant - build the full chain from ancestor to active
37
+ // The ancestorChain contains [descendant, parent, ..., ancestor] (walking up)
38
+ // We need [ancestor (effect), ..., parent, descendant, ...stack from descendant to active]
39
+ const chainFromAncestor = ancestorChain.reverse() // [ancestor, ..., descendant]
40
+ // Prepend the actual effect we're checking (in case current is a wrapper)
41
+ if (chainFromAncestor[0] !== rootEffect) {
42
+ chainFromAncestor.unshift(rootEffect)
43
+ }
44
+ // Append the rest of the stack from the descendant to the active effect
45
+ const stackFromDescendant = stack.slice(0, i + 1).reverse() // [descendant, ..., active]
46
+ // Remove duplicate descendant (it's both at end of chainFromAncestor and start of stackFromDescendant)
47
+ if (chainFromAncestor.length > 0 && stackFromDescendant.length > 0) {
48
+ stackFromDescendant.shift() // Remove duplicate descendant
49
+ }
50
+ return [...chainFromAncestor, ...stackFromDescendant]
51
+ }
52
+ current = effectParent.get(current)
53
+ }
54
+ }
55
+
56
+ return false
57
+ }
58
+ export function withEffectStack<T>(snapshot: (ScopedCallback | undefined)[], fn: () => T): T {
59
+ const previousStack = stack.slice()
60
+ assignStack(snapshot)
61
+ try {
62
+ return fn()
63
+ } finally {
64
+ assignStack(previousStack)
65
+ }
66
+ }
67
+
68
+ export function getActiveEffect() {
69
+ return stack[0]
70
+ }
71
+
72
+ /**
73
+ * Executes a function with a specific effect context
74
+ * @param effect - The effect to use as context
75
+ * @param fn - The function to execute
76
+ * @param keepParent - Whether to keep the parent effect context
77
+ * @returns The result of the function
78
+ */
79
+ export function withEffect<T>(effect: ScopedCallback | undefined, fn: () => T): T {
80
+ // console.log('[Mutts] withEffect', effect ? 'Active' : 'NULL');
81
+ if (getRoot(effect) === getRoot(getActiveEffect())) return fn()
82
+ stack.unshift(effect)
83
+ try {
84
+ return fn()
85
+ } finally {
86
+ const recoveredEffect = stack.shift()
87
+ if (recoveredEffect !== effect) throw new ReactiveError('[reactive] Effect stack mismatch')
88
+ }
89
+ }
90
+
91
+ function assignStack(values: (ScopedCallback | undefined)[]) {
92
+ stack.length = 0
93
+ stack.push(...values)
94
+ }