mutts 1.0.0 → 1.0.1

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 (53) hide show
  1. package/dist/chunks/{decorator-BXsign4Z.js → decorator-8qjFb7dw.js} +2 -2
  2. package/dist/chunks/decorator-8qjFb7dw.js.map +1 -0
  3. package/dist/chunks/{decorator-CPbZNnsX.esm.js → decorator-AbRkXM5O.esm.js} +2 -2
  4. package/dist/chunks/decorator-AbRkXM5O.esm.js.map +1 -0
  5. package/dist/decorator.d.ts +1 -1
  6. package/dist/decorator.esm.js +1 -1
  7. package/dist/decorator.js +1 -1
  8. package/dist/destroyable.esm.js +1 -1
  9. package/dist/destroyable.js +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.esm.js +2 -2
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/mutts.umd.js +1 -1
  15. package/dist/mutts.umd.js.map +1 -1
  16. package/dist/mutts.umd.min.js +1 -1
  17. package/dist/mutts.umd.min.js.map +1 -1
  18. package/dist/reactive.d.ts +4 -3
  19. package/dist/reactive.esm.js +61 -57
  20. package/dist/reactive.esm.js.map +1 -1
  21. package/dist/reactive.js +61 -56
  22. package/dist/reactive.js.map +1 -1
  23. package/dist/std-decorators.esm.js +1 -1
  24. package/dist/std-decorators.js +1 -1
  25. package/docs/reactive.md +616 -0
  26. package/package.json +1 -2
  27. package/dist/chunks/decorator-BXsign4Z.js.map +0 -1
  28. package/dist/chunks/decorator-CPbZNnsX.esm.js.map +0 -1
  29. package/src/decorator.test.ts +0 -495
  30. package/src/decorator.ts +0 -205
  31. package/src/destroyable.test.ts +0 -155
  32. package/src/destroyable.ts +0 -158
  33. package/src/eventful.test.ts +0 -380
  34. package/src/eventful.ts +0 -69
  35. package/src/index.ts +0 -7
  36. package/src/indexable.test.ts +0 -388
  37. package/src/indexable.ts +0 -124
  38. package/src/promiseChain.test.ts +0 -201
  39. package/src/promiseChain.ts +0 -99
  40. package/src/reactive/array.test.ts +0 -923
  41. package/src/reactive/array.ts +0 -352
  42. package/src/reactive/core.test.ts +0 -1663
  43. package/src/reactive/core.ts +0 -866
  44. package/src/reactive/index.ts +0 -28
  45. package/src/reactive/interface.test.ts +0 -1477
  46. package/src/reactive/interface.ts +0 -231
  47. package/src/reactive/map.test.ts +0 -866
  48. package/src/reactive/map.ts +0 -162
  49. package/src/reactive/set.test.ts +0 -289
  50. package/src/reactive/set.ts +0 -142
  51. package/src/std-decorators.test.ts +0 -679
  52. package/src/std-decorators.ts +0 -182
  53. package/src/utils.ts +0 -52
@@ -1,866 +0,0 @@
1
- // biome-ignore-all lint/suspicious/noConfusingVoidType: Type 'void' is not assignable to type 'ScopedCallback | undefined'.
2
- // Argument of type '() => void' is not assignable to parameter of type '(dep: DependencyFunction) => ScopedCallback | undefined'.
3
-
4
- import { decorator } from '../decorator'
5
-
6
- export type DependencyFunction = <T>(cb: () => T) => T
7
- // TODO: proper async management, read when fn returns a promise and let the effect as "running",
8
- // either to cancel the running one or to avoid running 2 in "parallel" and debounce the second one
9
-
10
- // TODO: generic "batch" forcing even if not in an effect (perhaps when calling a reactive' function ?)
11
- // example: storage will make 2 modifications (add slot, modify count), they could raise 2 effects
12
- export type ScopedCallback = () => void
13
-
14
- export type PropEvolution = {
15
- type: 'set' | 'del' | 'add'
16
- prop: any
17
- }
18
-
19
- export type BunchEvolution = {
20
- type: 'bunch'
21
- method: string
22
- }
23
- type Evolution = PropEvolution | BunchEvolution
24
-
25
- type State =
26
- | {
27
- evolution: Evolution
28
- next: State
29
- }
30
- | {}
31
- // Track which effects are watching which reactive objects for cleanup
32
- const effectToReactiveObjects = new WeakMap<ScopedCallback, Set<object>>()
33
-
34
- // Track object -> proxy and proxy -> object relationships
35
- const objectToProxy = new WeakMap<object, object>()
36
- const proxyToObject = new WeakMap<object, object>()
37
- // Deep watching data structures
38
- // Track which objects contain which other objects (back-references)
39
- const objectParents = new WeakMap<object, Set<{ parent: object; prop: PropertyKey }>>()
40
-
41
- // Track which objects have deep watchers
42
- const objectsWithDeepWatchers = new WeakSet<object>()
43
-
44
- // Track deep watchers per object
45
- const deepWatchers = new WeakMap<object, Set<ScopedCallback>>()
46
-
47
- // Track which effects are doing deep watching
48
- const effectToDeepWatchedObjects = new WeakMap<ScopedCallback, Set<object>>()
49
-
50
- // Track objects that should never be reactive and cannot be modified
51
- export const nonReactiveObjects = new WeakSet<object>()
52
-
53
- /**
54
- * Converts an iterator to a generator that yields reactive values
55
- */
56
- export function* makeReactiveIterator<T>(iterator: Iterator<T>): Generator<T> {
57
- let result = iterator.next()
58
- while (!result.done) {
59
- yield reactive(result.value)
60
- result = iterator.next()
61
- }
62
- }
63
-
64
- /**
65
- * Converts an iterator of key-value pairs to a generator that yields reactive key-value pairs
66
- */
67
- export function* makeReactiveEntriesIterator<K, V>(iterator: Iterator<[K, V]>): Generator<[K, V]> {
68
- let result = iterator.next()
69
- while (!result.done) {
70
- const [key, value] = result.value
71
- yield [reactive(key), reactive(value)]
72
- result = iterator.next()
73
- }
74
- }
75
-
76
- // Track effects per reactive object and property
77
- const watchers = new WeakMap<object, Map<any, Set<ScopedCallback>>>()
78
-
79
- export const profileInfo: any = {
80
- objectToProxy,
81
- proxyToObject,
82
- effectToReactiveObjects,
83
- watchers,
84
- objectParents,
85
- objectsWithDeepWatchers,
86
- deepWatchers,
87
- effectToDeepWatchedObjects,
88
- nonReactiveObjects,
89
- }
90
- // Track native reactivity
91
- const nativeReactive = Symbol('native-reactive')
92
-
93
- // Symbol to mark individual objects as non-reactive
94
- export const nonReactiveMark = Symbol('non-reactive')
95
- // Symbol to mark class properties as non-reactive
96
- export const unreactiveProperties = Symbol('unreactive-properties')
97
- export const prototypeForwarding: unique symbol = Symbol('prototype-forwarding')
98
-
99
- export const allProps = Symbol('all-props')
100
-
101
- // Symbol to mark functions with their root function
102
- const rootFunction = Symbol('root-function')
103
-
104
- /**
105
- * Mark a function with its root function. If the function already has a root,
106
- * the root becomes the root of the new root (transitive root tracking).
107
- * @param fn - The function to mark
108
- * @param root - The root function to associate with fn
109
- */
110
- export function markWithRoot<T extends Function>(fn: T, root: Function): T {
111
- // Mark fn with the new root
112
- return Object.defineProperty(fn, rootFunction, {
113
- value: getRoot(root),
114
- writable: false,
115
- })
116
- }
117
-
118
- /**
119
- * Retrieve the root function from a callback. Returns the function itself if it has no root.
120
- * @param fn - The function to get the root from
121
- * @returns The root function, or the function itself if no root exists
122
- */
123
- export function getRoot<T extends Function | undefined>(fn: T): T {
124
- return (fn as any)?.[rootFunction] || fn
125
- }
126
-
127
- export class ReactiveError extends Error {
128
- constructor(message: string) {
129
- super(message)
130
- this.name = 'ReactiveError'
131
- }
132
- }
133
-
134
- // biome-ignore-start lint/correctness/noUnusedFunctionParameters: Interface declaration with empty defaults
135
- /**
136
- * Options for the reactive system, can be configured at runtime
137
- */
138
- export const options = {
139
- /**
140
- * Debug purpose: called when an effect is entered
141
- * @param effect - The effect that is entered
142
- */
143
- enter: (effect: Function) => {},
144
- /**
145
- * Debug purpose: called when an effect is left
146
- * @param effect - The effect that is left
147
- */
148
- leave: (effect: Function) => {},
149
- /**
150
- * Debug purpose: called when an effect is chained
151
- * @param target - The effect that is being triggered
152
- * @param caller - The effect that is calling the target
153
- */
154
- chain: (target: Function, caller?: Function) => {},
155
- /**
156
- * Debug purpose: maximum effect chain (like call stack max depth)
157
- * Used to prevent infinite loops
158
- * @default 100
159
- */
160
- maxEffectChain: 100,
161
- /**
162
- * Maximum depth for deep watching traversal
163
- * Used to prevent infinite recursion in circular references
164
- * @default 100
165
- */
166
- maxDeepWatchDepth: 100,
167
- /**
168
- * Only react on instance members modification (not inherited properties)
169
- * For instance, do not track class methods
170
- * @default true
171
- */
172
- instanceMembers: true,
173
- // biome-ignore lint/suspicious/noConsole: This is the whole point here
174
- warn: (...args: any[]) => console.warn(...args),
175
- }
176
- // biome-ignore-end lint/correctness/noUnusedFunctionParameters: Interface declaration with empty defaults
177
-
178
- //#region evolution
179
-
180
- function raiseDeps(objectWatchers: Map<any, Set<ScopedCallback>>, ...keyChains: Iterable<any>[]) {
181
- for (const keys of keyChains)
182
- for (const key of keys) {
183
- const deps = objectWatchers.get(key)
184
- if (deps) for (const effect of Array.from(deps)) hasEffect(effect)
185
- }
186
- }
187
-
188
- export function touched1(obj: any, evolution: Evolution, prop: any) {
189
- touched(obj, evolution, [prop])
190
- }
191
-
192
- export function touched(obj: any, evolution: Evolution, props?: Iterable<any>) {
193
- obj = unwrap(obj)
194
- addState(obj, evolution)
195
- const objectWatchers = watchers.get(obj)
196
- if (objectWatchers) {
197
- if (props) raiseDeps(objectWatchers, [allProps], props)
198
- else raiseDeps(objectWatchers, objectWatchers.keys())
199
- }
200
-
201
- // Bubble up changes if this object has deep watchers
202
- if (objectsWithDeepWatchers.has(obj)) {
203
- bubbleUpChange(obj, evolution)
204
- }
205
- }
206
-
207
- const states = new WeakMap<object, State>()
208
-
209
- function addState(obj: any, evolution: Evolution) {
210
- obj = unwrap(obj)
211
- const next = {}
212
- const state = getState(obj)
213
- if (state) Object.assign(state, { evolution, next })
214
- states.set(obj, next)
215
- }
216
-
217
- export function getState(obj: any) {
218
- obj = unwrap(obj)
219
- let state = states.get(obj)
220
- if (!state) {
221
- state = {}
222
- states.set(obj, state)
223
- }
224
- return state
225
- }
226
-
227
- export function dependant(obj: any, prop: any = allProps) {
228
- // TODO: avoid depending on property get?
229
- obj = unwrap(obj)
230
- if (activeEffect && (typeof prop !== 'symbol' || prop === allProps)) {
231
- let objectWatchers = watchers.get(obj)
232
- if (!objectWatchers) {
233
- objectWatchers = new Map<PropertyKey, Set<ScopedCallback>>()
234
- watchers.set(obj, objectWatchers)
235
- }
236
- let deps = objectWatchers.get(prop)
237
- if (!deps) {
238
- deps = new Set<ScopedCallback>()
239
- objectWatchers.set(prop, deps)
240
- }
241
- deps.add(activeEffect)
242
-
243
- // Track which reactive objects this effect is watching
244
- let effectObjects = effectToReactiveObjects.get(activeEffect)
245
- if (!effectObjects) {
246
- effectObjects = new Set<object>()
247
- effectToReactiveObjects.set(activeEffect, effectObjects)
248
- }
249
- effectObjects.add(obj)
250
- }
251
- }
252
-
253
- // Stack of active effects to handle nested effects
254
- let activeEffect: ScopedCallback | undefined
255
- // Parent effect used for lifecycle/cleanup relationships (can diverge later)
256
- let parentEffect: ScopedCallback | undefined
257
-
258
- // Track currently executing effects to prevent re-execution
259
- // These are all the effects triggered under `activeEffect`
260
- let batchedEffects: Map<Function, ScopedCallback> | undefined
261
-
262
- // Track which sub-effects have been executed to prevent infinite loops
263
- // These are all the effects triggered under `activeEffect` and all their sub-effects
264
- function hasEffect(effect: ScopedCallback) {
265
- const root = getRoot(effect)
266
-
267
- options?.chain(getRoot(effect), getRoot(activeEffect))
268
- if (batchedEffects) batchedEffects.set(root, effect)
269
- else {
270
- const runEffects: any[] = []
271
- batchedEffects = new Map<Function, ScopedCallback>([[root, effect]])
272
- try {
273
- while (batchedEffects.size) {
274
- if (runEffects.length > options.maxEffectChain)
275
- throw new ReactiveError('[reactive] Max effect chain reached')
276
- const [root, effect] = batchedEffects.entries().next().value!
277
- runEffects.push(root)
278
- effect()
279
- batchedEffects.delete(root)
280
- }
281
- } finally {
282
- batchedEffects = undefined
283
- }
284
- }
285
- }
286
-
287
- export function withEffect<T>(
288
- effect: ScopedCallback | undefined,
289
- fn: () => T,
290
- keepParent?: true
291
- ): T {
292
- if (getRoot(effect) === getRoot(activeEffect)) return fn()
293
- const oldActiveEffect = activeEffect
294
- const oldParentEffect = parentEffect
295
- activeEffect = effect
296
- if (!keepParent) parentEffect = effect
297
- try {
298
- return fn()
299
- } finally {
300
- activeEffect = oldActiveEffect
301
- parentEffect = oldParentEffect
302
- }
303
- }
304
-
305
- //#endregion
306
-
307
- //#region deep watching
308
-
309
- /**
310
- * Add a back-reference from child to parent
311
- */
312
- function addBackReference(child: object, parent: object, prop: any) {
313
- let parents = objectParents.get(child)
314
- if (!parents) {
315
- parents = new Set()
316
- objectParents.set(child, parents)
317
- }
318
- parents.add({ parent, prop })
319
- }
320
-
321
- /**
322
- * Remove a back-reference from child to parent
323
- */
324
- function removeBackReference(child: object, parent: object, prop: any) {
325
- const parents = objectParents.get(child)
326
- if (parents) {
327
- parents.delete({ parent, prop })
328
- if (parents.size === 0) {
329
- objectParents.delete(child)
330
- }
331
- }
332
- }
333
-
334
- /**
335
- * Check if an object needs back-references (has deep watchers or parents with deep watchers)
336
- */
337
- function needsBackReferences(obj: object): boolean {
338
- return objectsWithDeepWatchers.has(obj) || hasParentWithDeepWatchers(obj)
339
- }
340
-
341
- /**
342
- * Check if an object has any parent with deep watchers
343
- */
344
- function hasParentWithDeepWatchers(obj: object): boolean {
345
- const parents = objectParents.get(obj)
346
- if (!parents) return false
347
-
348
- for (const { parent } of parents) {
349
- if (objectsWithDeepWatchers.has(parent)) return true
350
- if (hasParentWithDeepWatchers(parent)) return true
351
- }
352
- return false
353
- }
354
-
355
- /**
356
- * Bubble up changes through the back-reference chain
357
- */
358
- function bubbleUpChange(changedObject: object, evolution: Evolution) {
359
- const parents = objectParents.get(changedObject)
360
- if (!parents) return
361
-
362
- for (const { parent } of parents) {
363
- // Trigger deep watchers on parent
364
- const parentDeepWatchers = deepWatchers.get(parent)
365
- if (parentDeepWatchers) for (const watcher of parentDeepWatchers) hasEffect(watcher)
366
-
367
- // Continue bubbling up
368
- bubbleUpChange(parent, evolution)
369
- }
370
- }
371
-
372
- export function track1(obj: object, prop: any, oldVal: any, newValue: any) {
373
- // Manage back-references if this object has deep watchers
374
- if (objectsWithDeepWatchers.has(obj)) {
375
- // Remove old back-references
376
- if (typeof oldVal === 'object' && oldVal !== null) {
377
- removeBackReference(oldVal, obj, prop)
378
- }
379
-
380
- // Add new back-references
381
- if (typeof newValue === 'object' && newValue !== null) {
382
- const reactiveValue = reactive(newValue)
383
- addBackReference(reactiveValue, obj, prop)
384
- }
385
- }
386
- return newValue
387
- }
388
-
389
- //#endregion
390
-
391
- const reactiveHandlers = {
392
- [Symbol.toStringTag]: 'MutTs Reactive',
393
- get(obj: any, prop: PropertyKey, receiver: any) {
394
- if (prop === nonReactiveMark) return false
395
- // Check if this property is marked as unreactive
396
- if (obj[unreactiveProperties]?.has(prop) || typeof prop === 'symbol')
397
- return Reflect.get(obj, prop, receiver)
398
- const absent = !(prop in obj)
399
- // Depend if...
400
- if (!options.instanceMembers || Object.hasOwn(receiver, prop) || absent) dependant(obj, prop)
401
-
402
- const value = Reflect.get(obj, prop, receiver)
403
- if (typeof value === 'object' && value !== null) {
404
- const reactiveValue = reactive(value)
405
-
406
- // Only create back-references if this object needs them
407
- if (needsBackReferences(obj)) {
408
- addBackReference(reactiveValue, obj, prop)
409
- }
410
-
411
- return reactiveValue
412
- }
413
- return value
414
- },
415
- set(obj: any, prop: PropertyKey, value: any, receiver: any): boolean {
416
- // Check if this property is marked as unreactive
417
- if (obj[unreactiveProperties]?.has(prop)) return Reflect.set(obj, prop, value, receiver)
418
- // Really specific case for when Array is forwarder, in order to let it manage the reactivity
419
- const isArrayCase =
420
- prototypeForwarding in obj &&
421
- // biome-ignore lint/suspicious/useIsArray: This is the whole point here
422
- obj[prototypeForwarding] instanceof Array &&
423
- (!Number.isNaN(Number(prop)) || prop === 'length')
424
- const newValue = unwrap(value)
425
-
426
- if (isArrayCase) {
427
- ;(obj as any)[prop] = newValue
428
- return true
429
- }
430
-
431
- const oldVal = (obj as any)[prop]
432
- const oldPresent = prop in obj
433
- track1(obj, prop, oldVal, newValue)
434
-
435
- if (oldVal !== newValue) {
436
- Reflect.set(obj, prop, newValue, receiver)
437
- // try to find a "generic" way to express that
438
- touched1(obj, { type: oldPresent ? 'set' : 'add', prop }, prop)
439
- }
440
- return true
441
- },
442
- deleteProperty(obj: any, prop: PropertyKey): boolean {
443
- if (!Object.hasOwn(obj, prop)) return false
444
-
445
- const oldVal = (obj as any)[prop]
446
-
447
- // Remove back-references if this object has deep watchers
448
- if (objectsWithDeepWatchers.has(obj) && typeof oldVal === 'object' && oldVal !== null) {
449
- removeBackReference(oldVal, obj, prop)
450
- }
451
-
452
- delete (obj as any)[prop]
453
- touched1(obj, { type: 'del', prop }, prop)
454
-
455
- // Bubble up changes if this object has deep watchers
456
- if (objectsWithDeepWatchers.has(obj)) {
457
- bubbleUpChange(obj, { type: 'del', prop })
458
- }
459
-
460
- return true
461
- },
462
- getPrototypeOf(obj: any): object | null {
463
- if (prototypeForwarding in obj) return obj[prototypeForwarding]
464
- return Object.getPrototypeOf(obj)
465
- },
466
- setPrototypeOf(obj: any, proto: object | null): boolean {
467
- if (prototypeForwarding in obj) return false
468
- Object.setPrototypeOf(obj, proto)
469
- return true
470
- },
471
- ownKeys(obj: any): (string | symbol)[] {
472
- dependant(obj, allProps)
473
- return Reflect.ownKeys(obj)
474
- },
475
- } as const
476
-
477
- const reactiveClasses = new WeakSet<Function>()
478
- export class ReactiveBase {
479
- constructor() {
480
- // biome-ignore lint/correctness/noConstructorReturn: This is the whole point here
481
- return reactiveClasses.has(new.target) ? reactive(this) : this
482
- }
483
- }
484
-
485
- function reactiveObject<T>(anyTarget: T): T {
486
- if (!anyTarget || typeof anyTarget !== 'object') return anyTarget
487
- const target = anyTarget as any
488
- // If target is already a proxy, return it
489
- if (proxyToObject.has(target) || isNonReactive(target)) return target as T
490
-
491
- // If we already have a proxy for this object, return it
492
- if (objectToProxy.has(target)) return objectToProxy.get(target) as T
493
-
494
- const proxied =
495
- nativeReactive in target && !(target instanceof target[nativeReactive])
496
- ? new target[nativeReactive](target)
497
- : target
498
- if (proxied !== target) proxyToObject.set(proxied, target)
499
- const proxy = new Proxy(proxied, reactiveHandlers)
500
-
501
- // Store the relationships
502
- objectToProxy.set(target, proxy)
503
- proxyToObject.set(proxy, target)
504
- return proxy as T
505
- }
506
-
507
- export const reactive = decorator({
508
- class(original) {
509
- if (original.prototype instanceof ReactiveBase) {
510
- reactiveClasses.add(original)
511
- return original
512
- }
513
- class Reactive extends original {
514
- constructor(...args: any[]) {
515
- super(...args)
516
- if (new.target !== Reactive && !reactiveClasses.has(new.target))
517
- options.warn(
518
- `${(original as any).name} has been inherited by ${this.constructor.name} that is not reactive.
519
- @reactive decorator must be applied to the leaf class OR classes have to extend ReactiveBase.`
520
- )
521
- // biome-ignore lint/correctness/noConstructorReturn: This is the whole point here
522
- return reactive(this)
523
- }
524
- }
525
- Object.defineProperty(Reactive, 'name', {
526
- value: `Reactive<${original.name}>`,
527
- })
528
- return Reactive as any
529
- },
530
- get(original) {
531
- return reactiveObject(original)
532
- },
533
- default: reactiveObject,
534
- })
535
-
536
- export function unwrap<T>(proxy: T): T {
537
- // Return the original object
538
- return (proxyToObject.get(proxy as any) as T) ?? proxy
539
- }
540
-
541
- export function isReactive(obj: any): boolean {
542
- return proxyToObject.has(obj)
543
- }
544
- export function untracked(fn: () => ScopedCallback | undefined | void) {
545
- withEffect(undefined, fn, true)
546
- }
547
-
548
- // runEffect -> set<cleanup>
549
- const effectChildren = new WeakMap<ScopedCallback, Set<ScopedCallback>>()
550
- const fr = new FinalizationRegistry<() => void>((f) => f())
551
-
552
- /**
553
- * @param fn - The effect function to run - provides the cleaner
554
- * @returns The cleanup function
555
- */
556
- export function effect<Args extends any[]>(
557
- fn: (dep: DependencyFunction, ...args: Args) => ScopedCallback | undefined | void,
558
- ...args: Args
559
- ): ScopedCallback {
560
- let cleanup: (() => void) | null = null
561
- const dep = markWithRoot(<T>(cb: () => T) => withEffect(runEffect, cb), fn)
562
- let effectStopped = false
563
-
564
- function runEffect() {
565
- // Clear previous dependencies
566
- cleanup?.()
567
-
568
- options.enter(fn)
569
- const reactionCleanup = withEffect(effectStopped ? undefined : runEffect, () =>
570
- fn(dep, ...args)
571
- ) as undefined | ScopedCallback
572
- options.leave(fn)
573
-
574
- // Create cleanup function for next run
575
- cleanup = () => {
576
- cleanup = null
577
- reactionCleanup?.()
578
- // Remove this effect from all reactive objects it's watching
579
- const effectObjects = effectToReactiveObjects.get(runEffect)
580
- if (effectObjects) {
581
- for (const reactiveObj of effectObjects) {
582
- const objectWatchers = watchers.get(reactiveObj)
583
- if (objectWatchers) {
584
- for (const [prop, deps] of objectWatchers.entries()) {
585
- deps.delete(runEffect)
586
- if (deps.size === 0) {
587
- objectWatchers.delete(prop)
588
- }
589
- }
590
- if (objectWatchers.size === 0) {
591
- watchers.delete(reactiveObj)
592
- }
593
- }
594
- }
595
- effectToReactiveObjects.delete(runEffect)
596
- }
597
- }
598
- }
599
- // Mark the runEffect callback with the original function as its root
600
- markWithRoot(runEffect, fn)
601
-
602
- // Run the effect immediately
603
- if (!batchedEffects) {
604
- hasEffect(runEffect)
605
- } else {
606
- const oldBatchedEffects = batchedEffects
607
- try {
608
- // Simulate a hasEffect who batches, but do not execute the batch, give it back to the parent batch,
609
- // Only the immediate effect has to be executed, the sub-effects will be executed by the parent batch
610
- batchedEffects = new Map([[fn, runEffect]])
611
- runEffect()
612
- batchedEffects.delete(fn)
613
- for (const [root, effect] of batchedEffects!) oldBatchedEffects.set(root, effect)
614
- } finally {
615
- batchedEffects = oldBatchedEffects
616
- }
617
- }
618
-
619
- const mainCleanup = (): void => {
620
- if (effectStopped) return
621
- effectStopped = true
622
- cleanup?.()
623
- // Invoke all child cleanups (recursive via subEffectCleanup calling its own mainCleanup)
624
- const children = effectChildren.get(runEffect)
625
- if (children) {
626
- for (const childCleanup of children) childCleanup()
627
- effectChildren.delete(runEffect)
628
- }
629
-
630
- fr.unregister(mainCleanup)
631
- }
632
-
633
- // Only ROOT effects are registered for GC cleanup
634
- if (!parentEffect) {
635
- const callIfCollected = () => mainCleanup()
636
- fr.register(callIfCollected, mainCleanup, callIfCollected)
637
- return callIfCollected
638
- }
639
- // TODO: parentEffect = last non-undefined activeEffect
640
- // Register this effect to be cleaned up with the parent effect
641
- let children = effectChildren.get(parentEffect)
642
- if (!children) {
643
- children = new Set()
644
- effectChildren.set(parentEffect, children)
645
- }
646
- const parent = parentEffect
647
- const subEffectCleanup = (): void => {
648
- children.delete(subEffectCleanup)
649
- if (children.size === 0) {
650
- effectChildren.delete(parent)
651
- }
652
- // Execute this child effect cleanup (which triggers its own mainCleanup)
653
- mainCleanup()
654
- }
655
- children.add(subEffectCleanup)
656
- return subEffectCleanup
657
- }
658
-
659
- /**
660
- * Mark an object as non-reactive. This object and all its properties will never be made reactive.
661
- * @param obj - The object to mark as non-reactive
662
- */
663
- function nonReactive<T extends object[]>(...obj: T): T[0] {
664
- for (const o of obj) {
665
- try {
666
- Object.defineProperty(o, nonReactiveMark, {
667
- value: true,
668
- writable: false,
669
- enumerable: false,
670
- configurable: false,
671
- })
672
- } catch {}
673
- if (!(nonReactiveMark in (o as object))) nonReactiveObjects.add(o as object)
674
- }
675
- return obj[0]
676
- }
677
-
678
- /**
679
- * Set of functions to test if an object is immutable
680
- */
681
- export const immutables = new Set<(tested: any) => boolean>()
682
-
683
- /**
684
- * Check if an object is marked as non-reactive (for testing purposes)
685
- * @param obj - The object to check
686
- * @returns true if the object is marked as non-reactive
687
- */
688
- export function isNonReactive(obj: any): boolean {
689
- // Don't make primitives reactive
690
- if (obj === null || typeof obj !== 'object') return true
691
-
692
- // Check if the object itself is marked as non-reactive
693
- if (nonReactiveObjects.has(obj)) return true
694
-
695
- // Check if the object has the non-reactive symbol
696
- if (obj[nonReactiveMark]) return true
697
-
698
- // Check if the object is immutable
699
- if (Array.from(immutables).some((fn) => fn(obj))) return true
700
-
701
- return false
702
- }
703
-
704
- /**
705
- * Mark a class as non-reactive. All instances of this class will automatically be non-reactive.
706
- * @param cls - The class constructor to mark as non-reactive
707
- */
708
- export function nonReactiveClass<T extends (new (...args: any[]) => any)[]>(...cls: T): T[0] {
709
- for (const c of cls) if (c) (c.prototype as any)[nonReactiveMark] = true
710
- return cls[0]
711
- }
712
-
713
- nonReactiveClass(Date, RegExp, Error, Promise, Function)
714
- if (typeof window !== 'undefined') nonReactive(window, document)
715
- if (typeof Element !== 'undefined') nonReactiveClass(Element, Node)
716
-
717
- export function registerNativeReactivity(
718
- originalClass: new (...args: any[]) => any,
719
- reactiveClass: new (...args: any[]) => any
720
- ) {
721
- originalClass.prototype[nativeReactive] = reactiveClass
722
- nonReactiveClass(reactiveClass)
723
- }
724
-
725
- /**
726
- * Deep watch an object and all its nested properties
727
- * @param target - The object to watch deeply
728
- * @param callback - The callback to call when any nested property changes
729
- * @param options - Options for the deep watch
730
- * @returns A cleanup function to stop watching
731
- */
732
- export function deepWatch<T extends object>(
733
- target: T,
734
- callback: (value: T) => void,
735
- { immediate = false } = {}
736
- ): (() => void) | undefined {
737
- if (target === null || target === undefined) return undefined
738
- if (typeof target !== 'object') throw new Error('Target of deep watching must be an object')
739
- // Create a wrapper callback that matches ScopedCallback signature
740
- const wrappedCallback: ScopedCallback = markWithRoot(() => callback(target), callback)
741
-
742
- // Use the existing effect system to register dependencies
743
- return effect(() => {
744
- // Mark the target object as having deep watchers
745
- objectsWithDeepWatchers.add(target)
746
-
747
- // Track which objects this effect is watching for cleanup
748
- let effectObjects = effectToDeepWatchedObjects.get(wrappedCallback)
749
- if (!effectObjects) {
750
- effectObjects = new Set()
751
- effectToDeepWatchedObjects.set(wrappedCallback, effectObjects)
752
- }
753
- effectObjects!.add(target)
754
-
755
- // Traverse the object graph and register dependencies
756
- // This will re-run every time the effect runs, ensuring we catch all changes
757
- const visited = new WeakSet()
758
- function traverseAndTrack(obj: any, depth = 0) {
759
- // Prevent infinite recursion and excessive depth
760
- if (visited.has(obj) || !isObject(obj) || depth > options.maxDeepWatchDepth) return
761
- // Do not traverse into unreactive objects
762
- if (isNonReactive(obj)) return
763
- visited.add(obj)
764
-
765
- // Mark this object as having deep watchers
766
- objectsWithDeepWatchers.add(obj)
767
- effectObjects!.add(obj)
768
-
769
- // Traverse all properties to register dependencies
770
- // unwrap to avoid kicking dependency
771
- for (const key in unwrap(obj)) {
772
- if (Object.hasOwn(obj, key)) {
773
- // Access the property to register dependency
774
- const value = (obj as any)[key]
775
- // Make the value reactive if it's an object
776
- const reactiveValue =
777
- typeof value === 'object' && value !== null ? reactive(value) : value
778
- traverseAndTrack(reactiveValue, depth + 1)
779
- }
780
- }
781
-
782
- // Also handle array indices and length
783
- // biome-ignore lint/suspicious/useIsArray: Check for both native arrays and reactive arrays
784
- if (Array.isArray(obj) || obj instanceof Array) {
785
- // Access array length to register dependency on length changes
786
- const length = obj.length
787
-
788
- // Access all current array elements to register dependencies
789
- for (let i = 0; i < length; i++) {
790
- // Access the array element to register dependency
791
- const value = obj[i]
792
- // Make the value reactive if it's an object
793
- const reactiveValue =
794
- typeof value === 'object' && value !== null ? reactive(value) : value
795
- traverseAndTrack(reactiveValue, depth + 1)
796
- }
797
- }
798
- // Handle Set values (deep watch values only, not keys since Sets don't have separate keys)
799
- else if (obj instanceof Set) {
800
- // Access all Set values to register dependencies
801
- for (const value of obj) {
802
- // Make the value reactive if it's an object
803
- const reactiveValue =
804
- typeof value === 'object' && value !== null ? reactive(value) : value
805
- traverseAndTrack(reactiveValue, depth + 1)
806
- }
807
- }
808
- // Handle Map values (deep watch values only, not keys)
809
- else if (obj instanceof Map) {
810
- // Access all Map values to register dependencies
811
- for (const [_key, value] of obj) {
812
- // Make the value reactive if it's an object
813
- const reactiveValue =
814
- typeof value === 'object' && value !== null ? reactive(value) : value
815
- traverseAndTrack(reactiveValue, depth + 1)
816
- }
817
- }
818
- // Note: WeakSet and WeakMap cannot be iterated, so we can't deep watch their contents
819
- // They will only trigger when the collection itself is replaced
820
- }
821
-
822
- // Traverse the target object to register all dependencies
823
- // This will register dependencies on all current properties and array elements
824
- traverseAndTrack(target)
825
-
826
- // Only call the callback if immediate is true or if it's not the first run
827
- if (immediate) callback(target)
828
- immediate = true
829
-
830
- // Return a cleanup function that properly removes deep watcher tracking
831
- return () => {
832
- // Get the objects this effect was watching
833
- const effectObjects = effectToDeepWatchedObjects.get(wrappedCallback)
834
- if (effectObjects) {
835
- // Remove deep watcher tracking from all objects this effect was watching
836
- for (const obj of effectObjects) {
837
- // Check if this object still has other deep watchers
838
- const watchers = deepWatchers.get(obj)
839
- if (watchers) {
840
- // Remove this effect's callback from the watchers
841
- watchers.delete(wrappedCallback)
842
-
843
- // If no more watchers, remove the object from deep watchers tracking
844
- if (watchers.size === 0) {
845
- deepWatchers.delete(obj)
846
- objectsWithDeepWatchers.delete(obj)
847
- }
848
- } else {
849
- // No watchers found, remove from deep watchers tracking
850
- objectsWithDeepWatchers.delete(obj)
851
- }
852
- }
853
-
854
- // Clean up the tracking data
855
- effectToDeepWatchedObjects.delete(wrappedCallback)
856
- }
857
- }
858
- })
859
- }
860
-
861
- /**
862
- * Check if an object is an object (not null, not primitive)
863
- */
864
- function isObject(obj: any): obj is object {
865
- return obj !== null && typeof obj === 'object'
866
- }