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.
- package/README.md +14 -6
- package/dist/chunks/{_tslib-C-cuVLvZ.js → _tslib-BgjropY9.js} +9 -1
- package/dist/chunks/_tslib-BgjropY9.js.map +1 -0
- package/dist/chunks/{_tslib-CMEnd0VE.esm.js → _tslib-Mzh1rNsX.esm.js} +9 -2
- package/dist/chunks/_tslib-Mzh1rNsX.esm.js.map +1 -0
- package/dist/chunks/{decorator-D4DU97Zg.js → decorator-DLvrD0UF.js} +42 -19
- package/dist/chunks/decorator-DLvrD0UF.js.map +1 -0
- package/dist/chunks/{decorator-GnHw1Az7.esm.js → decorator-DqiszP7i.esm.js} +42 -19
- package/dist/chunks/decorator-DqiszP7i.esm.js.map +1 -0
- package/dist/chunks/index-79Kk8D6e.esm.js +4857 -0
- package/dist/chunks/index-79Kk8D6e.esm.js.map +1 -0
- package/dist/chunks/index-GRBSx0mB.js +4908 -0
- package/dist/chunks/index-GRBSx0mB.js.map +1 -0
- package/dist/decorator.esm.js +1 -1
- package/dist/decorator.js +1 -1
- package/dist/destroyable.d.ts +1 -1
- package/dist/destroyable.esm.js +1 -1
- package/dist/destroyable.esm.js.map +1 -1
- package/dist/destroyable.js +1 -1
- package/dist/destroyable.js.map +1 -1
- package/dist/devtools/devtools.html +9 -0
- package/dist/devtools/devtools.js +5 -0
- package/dist/devtools/devtools.js.map +1 -0
- package/dist/devtools/manifest.json +8 -0
- package/dist/devtools/panel.css +72 -0
- package/dist/devtools/panel.html +31 -0
- package/dist/devtools/panel.js +13048 -0
- package/dist/devtools/panel.js.map +1 -0
- package/dist/eventful.esm.js +1 -1
- package/dist/eventful.js +1 -1
- package/dist/index.d.ts +18 -63
- package/dist/index.esm.js +4 -4
- package/dist/index.js +37 -11
- package/dist/index.js.map +1 -1
- package/dist/indexable.d.ts +187 -1
- package/dist/indexable.esm.js +197 -3
- package/dist/indexable.esm.js.map +1 -1
- package/dist/indexable.js +198 -2
- package/dist/indexable.js.map +1 -1
- package/dist/mutts.umd.js +1 -1
- package/dist/mutts.umd.js.map +1 -1
- package/dist/mutts.umd.min.js +1 -1
- package/dist/mutts.umd.min.js.map +1 -1
- package/dist/promiseChain.esm.js.map +1 -1
- package/dist/promiseChain.js.map +1 -1
- package/dist/reactive.d.ts +602 -97
- package/dist/reactive.esm.js +3 -3
- package/dist/reactive.js +32 -10
- package/dist/reactive.js.map +1 -1
- package/dist/std-decorators.esm.js +1 -1
- package/dist/std-decorators.js +1 -1
- package/docs/ai/api-reference.md +133 -0
- package/docs/ai/manual.md +105 -0
- package/docs/iterableWeak.md +646 -0
- package/docs/reactive/advanced.md +1280 -0
- package/docs/reactive/collections.md +767 -0
- package/docs/reactive/core.md +973 -0
- package/docs/reactive.md +21 -9545
- package/package.json +18 -5
- package/src/decorator.ts +266 -0
- package/src/destroyable.ts +199 -0
- package/src/eventful.ts +77 -0
- package/src/index.d.ts +9 -0
- package/src/index.ts +9 -0
- package/src/indexable.ts +484 -0
- package/src/introspection.ts +59 -0
- package/src/iterableWeak.ts +233 -0
- package/src/mixins.ts +123 -0
- package/src/promiseChain.ts +110 -0
- package/src/reactive/array.ts +414 -0
- package/src/reactive/change.ts +134 -0
- package/src/reactive/debug.ts +517 -0
- package/src/reactive/deep-touch.ts +268 -0
- package/src/reactive/deep-watch-state.ts +82 -0
- package/src/reactive/deep-watch.ts +168 -0
- package/src/reactive/effect-context.ts +94 -0
- package/src/reactive/effects.ts +1345 -0
- package/src/reactive/index.ts +76 -0
- package/src/reactive/interface.ts +223 -0
- package/src/reactive/map.ts +171 -0
- package/src/reactive/mapped.ts +130 -0
- package/src/reactive/memoize.ts +107 -0
- package/src/reactive/non-reactive-state.ts +49 -0
- package/src/reactive/non-reactive.ts +43 -0
- package/src/reactive/project.project.md +93 -0
- package/src/reactive/project.ts +335 -0
- package/src/reactive/proxy-state.ts +27 -0
- package/src/reactive/proxy.ts +289 -0
- package/src/reactive/record.ts +196 -0
- package/src/reactive/register.ts +421 -0
- package/src/reactive/set.ts +144 -0
- package/src/reactive/tracking.ts +101 -0
- package/src/reactive/types.ts +358 -0
- package/src/reactive/zone.ts +208 -0
- package/src/std-decorators.ts +217 -0
- package/src/utils.ts +117 -0
- package/dist/chunks/_tslib-C-cuVLvZ.js.map +0 -1
- package/dist/chunks/_tslib-CMEnd0VE.esm.js.map +0 -1
- package/dist/chunks/decorator-D4DU97Zg.js.map +0 -1
- package/dist/chunks/decorator-GnHw1Az7.esm.js.map +0 -1
- package/dist/chunks/index-DBScoeCX.esm.js +0 -1960
- package/dist/chunks/index-DBScoeCX.esm.js.map +0 -1
- package/dist/chunks/index-DOTmXL89.js +0 -1983
- 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
|
+
}
|