mutts 1.0.6 → 1.0.7

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 (110) hide show
  1. package/README.md +1 -1
  2. package/dist/browser.d.ts +2 -0
  3. package/dist/browser.esm.js +70 -0
  4. package/dist/browser.esm.js.map +1 -0
  5. package/dist/browser.js +161 -0
  6. package/dist/browser.js.map +1 -0
  7. package/dist/chunks/{index-CDCOjzTy.js → index-BFYK02LG.js} +5760 -4338
  8. package/dist/chunks/index-BFYK02LG.js.map +1 -0
  9. package/dist/chunks/{index-DiP0RXoZ.esm.js → index-CNR6QRUl.esm.js} +5440 -4054
  10. package/dist/chunks/index-CNR6QRUl.esm.js.map +1 -0
  11. package/dist/devtools/panel.js.map +1 -1
  12. package/dist/mutts.umd.js +1 -1
  13. package/dist/mutts.umd.js.map +1 -1
  14. package/dist/mutts.umd.min.js +1 -1
  15. package/dist/mutts.umd.min.js.map +1 -1
  16. package/dist/node.d.ts +2 -0
  17. package/dist/node.esm.js +45 -0
  18. package/dist/node.esm.js.map +1 -0
  19. package/dist/node.js +136 -0
  20. package/dist/node.js.map +1 -0
  21. package/docs/ai/api-reference.md +0 -2
  22. package/docs/reactive/advanced.md +2 -5
  23. package/docs/reactive/collections.md +0 -125
  24. package/docs/reactive/core.md +27 -24
  25. package/docs/reactive/debugging.md +12 -2
  26. package/docs/reactive/project.md +1 -1
  27. package/docs/reactive/scan.md +78 -0
  28. package/docs/reactive.md +2 -1
  29. package/docs/std-decorators.md +1 -0
  30. package/docs/zone.md +88 -0
  31. package/package.json +42 -10
  32. package/src/async/browser.ts +87 -0
  33. package/src/async/index.ts +8 -0
  34. package/src/async/node.ts +46 -0
  35. package/src/decorator.ts +5 -1
  36. package/src/destroyable.ts +1 -1
  37. package/src/index.ts +22 -14
  38. package/src/indexable.ts +42 -0
  39. package/src/mixins.ts +2 -2
  40. package/src/reactive/array.ts +149 -141
  41. package/src/reactive/buffer.ts +168 -0
  42. package/src/reactive/change.ts +2 -2
  43. package/src/reactive/effect-context.ts +15 -91
  44. package/src/reactive/effects.ts +119 -179
  45. package/src/reactive/index.ts +10 -13
  46. package/src/reactive/interface.ts +19 -33
  47. package/src/reactive/map.ts +48 -61
  48. package/src/reactive/memoize.ts +19 -9
  49. package/src/reactive/project.ts +43 -22
  50. package/src/reactive/proxy.ts +16 -41
  51. package/src/reactive/record.ts +3 -3
  52. package/src/reactive/register.ts +5 -7
  53. package/src/reactive/registry.ts +9 -17
  54. package/src/reactive/set.ts +42 -56
  55. package/src/reactive/tracking.ts +1 -29
  56. package/src/reactive/types.ts +46 -23
  57. package/src/utils.ts +80 -37
  58. package/src/zone.ts +127 -0
  59. package/dist/chunks/_tslib-BgjropY9.js +0 -81
  60. package/dist/chunks/_tslib-BgjropY9.js.map +0 -1
  61. package/dist/chunks/_tslib-MCKDzsSq.esm.js +0 -75
  62. package/dist/chunks/_tslib-MCKDzsSq.esm.js.map +0 -1
  63. package/dist/chunks/decorator-BGILvPtN.esm.js +0 -627
  64. package/dist/chunks/decorator-BGILvPtN.esm.js.map +0 -1
  65. package/dist/chunks/decorator-BQ2eBTCj.js +0 -651
  66. package/dist/chunks/decorator-BQ2eBTCj.js.map +0 -1
  67. package/dist/chunks/index-CDCOjzTy.js.map +0 -1
  68. package/dist/chunks/index-DiP0RXoZ.esm.js.map +0 -1
  69. package/dist/decorator.d.ts +0 -107
  70. package/dist/decorator.esm.js +0 -2
  71. package/dist/decorator.esm.js.map +0 -1
  72. package/dist/decorator.js +0 -11
  73. package/dist/decorator.js.map +0 -1
  74. package/dist/destroyable.d.ts +0 -90
  75. package/dist/destroyable.esm.js +0 -109
  76. package/dist/destroyable.esm.js.map +0 -1
  77. package/dist/destroyable.js +0 -116
  78. package/dist/destroyable.js.map +0 -1
  79. package/dist/eventful.d.ts +0 -20
  80. package/dist/eventful.esm.js +0 -66
  81. package/dist/eventful.esm.js.map +0 -1
  82. package/dist/eventful.js +0 -68
  83. package/dist/eventful.js.map +0 -1
  84. package/dist/index.d.ts +0 -19
  85. package/dist/index.esm.js +0 -53
  86. package/dist/index.esm.js.map +0 -1
  87. package/dist/index.js +0 -139
  88. package/dist/index.js.map +0 -1
  89. package/dist/indexable.d.ts +0 -243
  90. package/dist/indexable.esm.js +0 -285
  91. package/dist/indexable.esm.js.map +0 -1
  92. package/dist/indexable.js +0 -291
  93. package/dist/indexable.js.map +0 -1
  94. package/dist/promiseChain.d.ts +0 -21
  95. package/dist/promiseChain.esm.js +0 -78
  96. package/dist/promiseChain.esm.js.map +0 -1
  97. package/dist/promiseChain.js +0 -80
  98. package/dist/promiseChain.js.map +0 -1
  99. package/dist/reactive.d.ts +0 -910
  100. package/dist/reactive.esm.js +0 -5
  101. package/dist/reactive.esm.js.map +0 -1
  102. package/dist/reactive.js +0 -59
  103. package/dist/reactive.js.map +0 -1
  104. package/dist/std-decorators.d.ts +0 -52
  105. package/dist/std-decorators.esm.js +0 -196
  106. package/dist/std-decorators.esm.js.map +0 -1
  107. package/dist/std-decorators.js +0 -204
  108. package/dist/std-decorators.js.map +0 -1
  109. package/src/reactive/mapped.ts +0 -129
  110. package/src/reactive/zone.ts +0 -208
@@ -46,7 +46,7 @@ export function collectEffects(
46
46
  for (const effect of deps) {
47
47
  const runningChain = isRunning(effect)
48
48
  if (runningChain) {
49
- options.skipRunningEffect(effect, runningChain as any)
49
+ options.skipRunningEffect(effect)
50
50
  continue
51
51
  }
52
52
  if (!effects.has(effect)) {
@@ -118,7 +118,7 @@ export function touchedOpaque(obj: any, evolution: Evolution, prop: any) {
118
118
 
119
119
  const runningChain = isRunning(effect)
120
120
  if (runningChain) {
121
- options.skipRunningEffect(effect, runningChain as any)
121
+ options.skipRunningEffect(effect)
122
122
  continue
123
123
  }
124
124
  effects.add(effect)
@@ -1,94 +1,18 @@
1
- import { effectParent, getRoot } from './registry'
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
- }
1
+ import { tag } from '../utils'
2
+ import { asyncZone, ZoneAggregator, ZoneHistory } from '../zone'
3
+ import { getRoot } from './registry'
4
+ import { type ScopedCallback } from './types'
5
+
6
+ export const effectHistory = tag(new ZoneHistory<ScopedCallback>(), 'effectHistory')
7
+ tag(effectHistory.present, 'effectHistory.present')
8
+ asyncZone.add(effectHistory)
9
+ export const effectAggregator = tag(new ZoneAggregator(effectHistory.present), 'effectAggregator')
10
+
11
+ export function isRunning(effect: ScopedCallback): boolean {
12
+ const root = getRoot(effect)
13
+ return effectHistory.some((e) => getRoot(e) === root)
66
14
  }
67
15
 
68
16
  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
-
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
- }
17
+ return effectHistory.present.active
18
+ }
@@ -2,16 +2,10 @@ import { decorator } from '../decorator'
2
2
  import { IterableWeakSet } from '../iterableWeak'
3
3
  import { getTriggerChain, isDevtoolsEnabled, registerEffectForDebug } from './debug'
4
4
  import {
5
- captureEffectStack,
6
- effectStack,
5
+ effectAggregator,
6
+ effectHistory,
7
7
  getActiveEffect,
8
- withEffect,
9
- withEffectStack,
10
8
  } from './effect-context'
11
- import {
12
- getTrackingDisabled,
13
- setTrackingDisabled,
14
- } from './tracking'
15
9
  import {
16
10
  effectChildren,
17
11
  effectParent,
@@ -21,6 +15,7 @@ import {
21
15
  watchers,
22
16
  } from './registry'
23
17
  import {
18
+ cleanup as cleanupSymbol,
24
19
  type DependencyAccess,
25
20
  type EffectOptions,
26
21
  type Evolution,
@@ -29,10 +24,11 @@ import {
29
24
  ReactiveErrorCode,
30
25
  // type AsyncExecutionMode,
31
26
  type ScopedCallback,
32
- cleanup as cleanupSymbol,
33
27
  stopped,
34
28
  } from './types'
35
29
 
30
+ import { unwrap } from './proxy-state'
31
+
36
32
  /**
37
33
  * Finds a cycle in a sequence of functions by looking for the first repetition
38
34
  */
@@ -59,11 +55,8 @@ function formatRoots(roots: Function[], limit = 20): string {
59
55
  return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`
60
56
  }
61
57
 
62
- import { ensureZoneHooked } from './zone'
63
-
64
58
  type EffectTracking = (obj: any, evolution: Evolution, prop: any) => void
65
59
 
66
- export { captureEffectStack, withEffectStack, getActiveEffect, effectStack }
67
60
  export interface ActivationRecord {
68
61
  effect: ScopedCallback
69
62
  obj: any
@@ -211,6 +204,7 @@ function getOrCreateClosure(
211
204
  * @param targetRoot - Root function of the effect being triggered
212
205
  */
213
206
  function addGraphEdge(callerRoot: Function, targetRoot: Function) {
207
+ if (options.cycleHandling === 'none') return
214
208
  // Skip if edge already exists
215
209
  const triggers = effectTriggers.get(callerRoot)
216
210
  if (triggers?.has(targetRoot)) {
@@ -336,6 +330,7 @@ function hasPathExcluding(start: Function, end: Function, exclude: Function): bo
336
330
  * @param effect - The effect being cleaned up
337
331
  */
338
332
  function cleanupEffectFromGraph(effect: ScopedCallback) {
333
+ if (options.cycleHandling === 'none') return
339
334
  const root = getRoot(effect)
340
335
 
341
336
  // Get closures before removing direct edges (needed for propagation)
@@ -458,6 +453,7 @@ const batchCleanups = new Set<ScopedCallback>()
458
453
  * Called once when batch starts or when new effects are added
459
454
  */
460
455
  function computeAllInDegrees(batch: BatchQueue): void {
456
+ if (options.cycleHandling === 'none') return
461
457
  const activeEffect = getActiveEffect()
462
458
  const activeRoot = activeEffect ? getRoot(activeEffect) : null
463
459
 
@@ -597,6 +593,10 @@ function getCyclePathForEdge(callerRoot: Function, targetRoot: Function): Functi
597
593
  * Checks if adding an edge would create a cycle
598
594
  * Uses causesClosure to check if callerRoot is already a cause of targetRoot
599
595
  * Self-loops (callerRoot === targetRoot) are explicitly ignored and return false
596
+ *
597
+ * **Note**: This is the primary optimization benefit of the transitive closure system.
598
+ * It allows detecting cycles in O(1) time before they are executed.
599
+ *
600
600
  * @param callerRoot - Root of the effect that triggers
601
601
  * @param targetRoot - Root of the effect being triggered
602
602
  * @returns true if adding this edge would create a cycle
@@ -626,8 +626,7 @@ function wouldCreateCycle(callerRoot: Function, targetRoot: Function): boolean {
626
626
  * @param immediate - If true, don't create edges in the dependency graph
627
627
  */
628
628
  function addToBatch(effect: ScopedCallback, caller?: ScopedCallback, immediate?: boolean) {
629
- const cleanupFn = (effect as any)[cleanupSymbol]
630
- if (cleanupFn) cleanupFn()
629
+ (effect as any)[cleanupSymbol]?.()
631
630
  // If the effect was stopped during cleanup (e.g. lazy memoization), don't add it to the batch
632
631
  if ((effect as any)[stopped]) return
633
632
 
@@ -636,11 +635,16 @@ function addToBatch(effect: ScopedCallback, caller?: ScopedCallback, immediate?:
636
635
  const root = getRoot(effect)
637
636
 
638
637
  // 1. Add to batch first (needed for cycle detection)
638
+ if (options.cycleHandling === 'none' && batchQueue.all.has(root)) {
639
+ // If already present in flat mode, remove it so that the next set puts it at the end
640
+ batchQueue.all.delete(root)
641
+ }
642
+
639
643
  batchQueue.all.set(root, effect)
640
644
 
641
645
  // 2. Add to global graph (if caller exists and not immediate) - USE ROOTS ONLY
642
646
  // When immediate is true, don't create edges - the effect is not considered as a consequence
643
- if (caller && !immediate) {
647
+ if (caller && !immediate && options.cycleHandling !== 'none') {
644
648
  const callerRoot = getRoot(caller)
645
649
 
646
650
  // Check for cycle BEFORE adding edge
@@ -807,14 +811,22 @@ function executeNext(effectuatedRoots: Function[]): any {
807
811
  let nextEffect: ScopedCallback | null = null
808
812
  let nextRoot: Function | null = null
809
813
 
810
- // Find an effect with in-degree 0 (no dependencies in batch that still need execution)
811
- // Using cached in-degrees for O(n) lookup instead of O()
812
- for (const [root, effect] of batchQueue!.all) {
813
- const inDegree = batchQueue!.inDegrees.get(root) ?? 0
814
- if (inDegree === 0) {
815
- nextEffect = effect
816
- nextRoot = root
817
- break
814
+ if (options.cycleHandling === 'none') {
815
+ // In flat mode, we just take the first effect in the queue (FIFO)
816
+ const first = batchQueue!.all.entries().next().value
817
+ if (first) {
818
+ ;[nextRoot, nextEffect] = first
819
+ }
820
+ } else {
821
+ // Find an effect with in-degree 0 (no dependencies in batch that still need execution)
822
+ // Using cached in-degrees for O(n) lookup instead of O(n²)
823
+ for (const [root, effect] of batchQueue!.all) {
824
+ const inDegree = batchQueue!.inDegrees.get(root) ?? 0
825
+ if (inDegree === 0) {
826
+ nextEffect = effect
827
+ nextRoot = root
828
+ break
829
+ }
818
830
  }
819
831
  }
820
832
 
@@ -907,9 +919,8 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
907
919
  // Nested batch - add to existing
908
920
  options?.chain(roots, getRoot(getActiveEffect()))
909
921
  const caller = getActiveEffect()
910
- for (let i = 0; i < effect.length; i++) {
922
+ for (let i = 0; i < effect.length; i++)
911
923
  addToBatch(effect[i], caller, immediate === 'immediate')
912
- }
913
924
  if (immediate) {
914
925
  const firstReturn: { value?: any } = {}
915
926
  // Execute immediately (before batch returns)
@@ -935,30 +946,30 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
935
946
  inDegrees: new Map(),
936
947
  }
937
948
 
938
- // Add initial effects
939
949
  const caller = getActiveEffect()
940
- for (let i = 0; i < effect.length; i++) {
941
- addToBatch(effect[i], caller, immediate === 'immediate')
942
- }
943
-
944
950
  const effectuatedRoots: ScopedCallback[] = []
945
- computeAllInDegrees(batchQueue)
946
- if (immediate) {
947
- // Execute immediately (before batch returns)
948
- const firstReturn: { value?: any } = {}
949
- try {
951
+ const firstReturn: { value?: any } = {}
952
+
953
+ try {
954
+ if (immediate) {
955
+ // Execute initial effects in providing order
950
956
  for (let i = 0; i < effect.length; i++) {
951
957
  try {
952
958
  const rv = effect[i]()
953
959
  if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
954
960
  } finally {
955
- const root = getRoot(effect[i])
956
- batchQueue.all.delete(root)
961
+ batchQueue.all.delete(getRoot(effect[i]))
957
962
  }
958
963
  }
959
- // After immediate execution, execute any effects that were triggered during execution
960
- // This is important for @atomic decorator - effects triggered inside should still run
961
- while (batchQueue.all.size > 0) {
964
+ } else {
965
+ // Add initial effects to batch and compute dependencies
966
+ for (let i = 0; i < effect.length; i++) addToBatch(effect[i], caller, false)
967
+ computeAllInDegrees(batchQueue)
968
+ }
969
+
970
+ // Processing loop for all triggered effects and cleanups
971
+ while (batchQueue.all.size > 0 || batchCleanups.size > 0) {
972
+ if (batchQueue.all.size > 0) {
962
973
  if (effectuatedRoots.length > options.maxEffectChain) {
963
974
  const cycle = findCycleInChain(effectuatedRoots as any)
964
975
  const trace = formatRoots(effectuatedRoots as any)
@@ -998,98 +1009,35 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
998
1009
  break
999
1010
  }
1000
1011
  }
1001
- if (!batchQueue || batchQueue.all.size === 0) break
1002
1012
  const rv = executeNext(effectuatedRoots)
1003
- // If executeNext() returned null but batch is not empty, it means a cycle was detected
1004
- // and an error was thrown, so we won't reach here
1005
1013
  if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
1014
+ } else {
1015
+ // Process cleanups. If they trigger more effects, they will be caught in the next iteration.
1016
+ const cleanups = Array.from(batchCleanups)
1017
+ batchCleanups.clear()
1018
+ for (const cleanup of cleanups) cleanup()
1019
+
1020
+ // In immediate mode, we traditionally don't process recursive effects from cleanups.
1021
+ // If we want to keep that behavior: if (immediate) break
1006
1022
  }
1007
- const cleanups = Array.from(batchCleanups)
1008
- batchCleanups.clear()
1009
- for (const cleanup of cleanups) cleanup()
1010
- return firstReturn.value
1011
- } finally {
1012
- activationRegistry = undefined
1013
- batchQueue = undefined
1014
- options.endChain()
1015
- }
1016
- } else {
1017
- // Execute in dependency order
1018
- const firstReturn: { value?: any } = {}
1019
- try {
1020
- // Outer loop: continue while there are effects OR cleanups pending.
1021
- // This ensures effects triggered by cleanups are not lost.
1022
- while (batchQueue.all.size > 0 || batchCleanups.size > 0) {
1023
- // Inner loop: execute all pending effects
1024
- while (batchQueue.all.size > 0) {
1025
- if (effectuatedRoots.length > options.maxEffectChain) {
1026
- const cycle = findCycleInChain(effectuatedRoots as any)
1027
- const trace = formatRoots(effectuatedRoots as any)
1028
- const message = cycle
1029
- ? `Max effect chain reached (cycle detected: ${formatRoots(cycle)})`
1030
- : `Max effect chain reached (trace: ${trace})`
1031
-
1032
- const queuedRoots = batchQueue ? Array.from(batchQueue.all.keys()) : []
1033
- const queued = queuedRoots.map((r) => r.name || '<anonymous>')
1034
- const debugInfo = {
1035
- code: ReactiveErrorCode.MaxDepthExceeded,
1036
- effectuatedRoots,
1037
- cycle,
1038
- trace,
1039
- maxEffectChain: options.maxEffectChain,
1040
- queued: queued.slice(0, 50),
1041
- queuedCount: queued.length,
1042
- // Try to get causation for the last effect
1043
- causalChain:
1044
- effectuatedRoots.length > 0
1045
- ? getTriggerChain(
1046
- batchQueue.all.get(effectuatedRoots[effectuatedRoots.length - 1])!
1047
- )
1048
- : [],
1049
- }
1050
- switch (options.maxEffectReaction) {
1051
- case 'throw':
1052
- throw new ReactiveError(`[reactive] ${message}`, debugInfo)
1053
- case 'debug':
1054
- // biome-ignore lint/suspicious/noDebugger: This is the whole point here
1055
- debugger
1056
- throw new ReactiveError(`[reactive] ${message}`, debugInfo)
1057
- case 'warn':
1058
- options.warn(
1059
- `[reactive] ${message} (queued: ${queued.slice(0, 10).join(', ')}${queued.length > 10 ? ', …' : ''})`
1060
- )
1061
- break
1062
- }
1063
- }
1064
- const rv = executeNext(effectuatedRoots)
1065
- // executeNext() returns null when batch is complete or cycle detected (throws error)
1066
- // But functions can legitimately return null, so we check batchQueue.all.size instead
1067
- if (batchQueue.all.size === 0) {
1068
- // Batch complete
1069
- break
1070
- }
1071
- // If executeNext() returned null but batch is not empty, it means a cycle was detected
1072
- // and an error was thrown, so we won't reach here
1073
- if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
1074
- // Note: executeNext() already removed it from batchQueue, so we track by count
1075
- }
1076
- // Process cleanups. If they trigger new effects, the outer loop will catch them.
1077
- if (batchCleanups.size > 0) {
1078
- const cleanups = Array.from(batchCleanups)
1079
- batchCleanups.clear()
1080
- for (const cleanup of cleanups) cleanup()
1081
- }
1082
- }
1083
- return firstReturn.value
1084
- } finally {
1085
- activationRegistry = undefined
1086
- batchQueue = undefined
1087
- options.endChain()
1088
1023
  }
1024
+ return firstReturn.value
1025
+ } catch (error) {
1026
+ throw error instanceof ReactiveError
1027
+ ? error
1028
+ : new ReactiveError('Effects are broken', { code: ReactiveErrorCode.BrokenEffects, cause: error })
1029
+ } finally {
1030
+ activationRegistry = undefined
1031
+ batchQueue = undefined
1032
+ options.endChain()
1089
1033
  }
1090
1034
  }
1091
1035
  }
1092
1036
 
1037
+ // Inject batch function to allow atomic game loops in requestAnimationFrame/setTimeout/...
1038
+ // TODO: perhaps introduce somewhere a way to wrap async functions - find out if it's necessary
1039
+ // wrapAsync(fn=> batch(fn, 'immediate'))
1040
+
1093
1041
  /**
1094
1042
  * Decorator that makes methods atomic - batches all effects triggered within the method
1095
1043
  */
@@ -1130,10 +1078,10 @@ export function effect(
1130
1078
  //biome-ignore lint/suspicious/noConfusingVoidType: We have to
1131
1079
  fn: (access: DependencyAccess) => ScopedCallback | undefined | void | Promise<any>,
1132
1080
  effectOptions?: EffectOptions
1133
- ): ScopedCallback {
1134
- // Ensure zone is hooked if asyncZone option is enabled (lazy initialization)
1135
- // Inject batch function to allow atomic game loops in requestAnimationFrame
1136
- ensureZoneHooked(batch)
1081
+ ): ScopedCallback & {
1082
+ [stopped]: boolean
1083
+ [cleanupSymbol]: () => void
1084
+ } {
1137
1085
 
1138
1086
  // Use per-effect asyncMode or fall back to global option
1139
1087
  const asyncMode = effectOptions?.asyncMode ?? options.asyncMode ?? 'cancel'
@@ -1146,10 +1094,13 @@ export function effect(
1146
1094
  }
1147
1095
  }
1148
1096
  let cleanup: (() => void) | null = null
1149
- // capture the parent effect at creation time for ascend
1150
- const parentsForAscend = captureEffectStack()
1151
- const tracked = <T>(cb: () => T) => withEffect(runEffect, cb)
1152
- const ascend = <T>(cb: () => T) => withEffectStack(parentsForAscend, cb)
1097
+ const tracked = effectHistory.present.with(runEffect, ()=> effectAggregator.zoned)
1098
+ const ascend = effectHistory.zoned
1099
+ //const parent = effectHistory.present.active // TODO: Double-check parenting (untracked -> stop children) - use case or untracked(effect)
1100
+ let parent = effectHistory.present.active
1101
+ /*if (!parent) {
1102
+ for (const h of effectHistory.active.history) parent = h
1103
+ }*/
1153
1104
  let effectStopped = false
1154
1105
  let hasReacted = false
1155
1106
  let runningPromise: Promise<any> | null = null
@@ -1160,7 +1111,7 @@ export function effect(
1160
1111
  if (cleanup) {
1161
1112
  const prevCleanup = cleanup
1162
1113
  cleanup = null
1163
- withEffect(undefined, () => prevCleanup())
1114
+ untracked(() => prevCleanup())
1164
1115
  }
1165
1116
 
1166
1117
  // Handle async modes when effect is retriggered
@@ -1184,7 +1135,7 @@ export function effect(
1184
1135
  let reactionCleanup: ScopedCallback | undefined
1185
1136
  let result: any
1186
1137
  try {
1187
- result = withEffect(runEffect, () => fn({ tracked, ascend, reaction: hasReacted }))
1138
+ result = tracked(() => fn({ tracked, ascend, reaction: hasReacted }))
1188
1139
  if (
1189
1140
  result &&
1190
1141
  typeof result !== 'function' &&
@@ -1260,6 +1211,28 @@ export function effect(
1260
1211
  }
1261
1212
  // Mark the runEffect callback with the original function as its root
1262
1213
  markWithRoot(runEffect, fn)
1214
+ function augmentedRv(rv: ScopedCallback) {
1215
+ return Object.defineProperties(rv, {
1216
+ [stopped]: {
1217
+ get() {
1218
+ return effectStopped
1219
+ },
1220
+ },
1221
+ [cleanupSymbol]: {
1222
+ value: () => {
1223
+ if (cleanup) {
1224
+ const prevCleanup = cleanup
1225
+ cleanup = null
1226
+ untracked(() => prevCleanup())
1227
+ }
1228
+ },
1229
+ },
1230
+ }) as ScopedCallback & {
1231
+ [stopped]: boolean
1232
+ [cleanupSymbol]: () => void
1233
+ }
1234
+ }
1235
+ augmentedRv(runEffect)
1263
1236
 
1264
1237
  // Register strict mode if enabled
1265
1238
  if (effectOptions?.opaque) {
@@ -1272,7 +1245,6 @@ export function effect(
1272
1245
 
1273
1246
  batch(runEffect, 'immediate')
1274
1247
 
1275
- const parent = parentsForAscend[0]
1276
1248
  // Store parent relationship for hierarchy traversal
1277
1249
  effectParent.set(runEffect, parent)
1278
1250
  // Only ROOT effects are registered for GC cleanup and zone tracking
@@ -1292,23 +1264,6 @@ export function effect(
1292
1264
  cleanupEffectFromGraph(runEffect)
1293
1265
  fr.unregister(stopEffect)
1294
1266
  }
1295
- function augmentedRv(rv: ScopedCallback): ScopedCallback {
1296
- Object.defineProperty(rv, stopped, {
1297
- get() {
1298
- return effectStopped
1299
- },
1300
- })
1301
- Object.defineProperty(rv, cleanupSymbol, {
1302
- value: () => {
1303
- if (cleanup) {
1304
- const prevCleanup = cleanup
1305
- cleanup = null
1306
- withEffect(undefined, () => prevCleanup())
1307
- }
1308
- },
1309
- })
1310
- return rv
1311
- }
1312
1267
  if (isRootEffect) {
1313
1268
  const callIfCollected = augmentedRv(() => stopEffect())
1314
1269
  fr.register(
@@ -1346,17 +1301,7 @@ export function effect(
1346
1301
  * @param fn - The function to execute
1347
1302
  */
1348
1303
  export function untracked<T>(fn: () => T): T {
1349
- // Store current tracking state and temporarily disable it
1350
- // This prevents the parent effect from tracking dependencies during fn execution
1351
- const wasTrackingDisabled = getTrackingDisabled()
1352
- setTrackingDisabled(true)
1353
-
1354
- try {
1355
- return fn()
1356
- } finally {
1357
- // Restore tracking state
1358
- setTrackingDisabled(wasTrackingDisabled)
1359
- }
1304
+ return effectHistory.present.root(fn)
1360
1305
  }
1361
1306
 
1362
1307
  /**
@@ -1365,11 +1310,7 @@ export function untracked<T>(fn: () => T): T {
1365
1310
  * @param fn - The function to execute
1366
1311
  */
1367
1312
  export function root<T>(fn: () => T): T {
1368
- let rv!: T
1369
- withEffect(undefined, () => {
1370
- rv = fn()
1371
- })
1372
- return rv
1313
+ return effectHistory.root(fn)
1373
1314
  }
1374
1315
 
1375
1316
  export { effectTrackers }
@@ -1425,18 +1366,17 @@ export function biDi<T>(
1425
1366
  set = get.set
1426
1367
  get = get.get
1427
1368
  }
1428
- const root = getRoot(received)
1369
+ let programatticallySetValue: any = Symbol()
1429
1370
  effect(
1430
1371
  markWithRoot(() => {
1431
- received(get())
1432
- }, root)
1372
+ const newValue = get()
1373
+ if (unwrap(newValue) !== programatticallySetValue) received(newValue)
1374
+ }, received)
1433
1375
  )
1434
- return atomic((value: T) => {
1435
- set!(value)
1436
- if (batchQueue?.all.has(root)) {
1437
- // Remove the effect from the batch queue so it doesn't execute
1438
- // This prevents circular updates in bidirectional bindings
1439
- batchQueue.all.delete(root)
1440
- }
1441
- })
1376
+ return set
1377
+ ? atomic((value: T) => {
1378
+ programatticallySetValue = unwrap(value)
1379
+ set(value)
1380
+ })
1381
+ : () => {}
1442
1382
  }
@@ -13,33 +13,30 @@ export { deepWatch } from './deep-watch'
13
13
  export {
14
14
  addBatchCleanup,
15
15
  atomic,
16
- batch, // TODO: Batch is now exported for testing purposes, though it shouldn't be - modify the tests to go through `atomic`
17
16
  biDi,
18
17
  defer,
19
18
  effect,
20
19
  getActivationLog,
21
- getActiveEffect,
22
20
  root,
23
21
  trackEffect,
24
22
  untracked,
25
23
  } from './effects'
26
24
  export { cleanedBy, cleanup, derived, unreactive, watch } from './interface'
27
- export { mapped, ReadOnlyError, reduced } from './mapped'
28
25
  export { type Memoizable, memoize } from './memoize'
29
26
  export { immutables, isNonReactive, registerNativeReactivity } from './non-reactive'
30
27
  export { getActiveProjection, project } from './project'
31
28
  export { isReactive, ReactiveBase, reactive, unwrap } from './proxy'
32
29
  export { organize, organized } from './record'
30
+ export { scan, type ScanResult } from './buffer'
33
31
  export { Register, register } from './register'
34
32
  export {
35
33
  type DependencyAccess,
36
- type DependencyFunction,
37
34
  type Evolution,
38
35
  options as reactiveOptions,
39
36
  ReactiveError,
37
+ ReactiveErrorCode,
40
38
  type ScopedCallback,
41
39
  } from './types'
42
- export { isZoneEnabled, setZoneEnabled } from './zone'
43
40
 
44
41
  import { ReactiveArray } from './array'
45
42
  import {
@@ -49,17 +46,17 @@ import {
49
46
  objectsWithDeepWatchers,
50
47
  } from './deep-watch'
51
48
  import { ReactiveMap, ReactiveWeakMap } from './map'
52
- import { nonReactiveObjects, registerNativeReactivity } from './non-reactive-state'
53
- import { objectToProxy, proxyToObject } from './proxy'
54
- import { ReactiveSet, ReactiveWeakSet } from './set'
49
+ import { nonReactiveObjects } from './non-reactive-state'
50
+ import { metaProtos, objectToProxy, proxyToObject } from './proxy'
55
51
  import { effectToReactiveObjects, watchers } from './registry'
52
+ import { ReactiveSet, ReactiveWeakSet } from './set'
56
53
 
57
54
  // Register native collection types to use specialized reactive wrappers
58
- registerNativeReactivity(WeakMap, ReactiveWeakMap)
59
- registerNativeReactivity(Map, ReactiveMap)
60
- registerNativeReactivity(WeakSet, ReactiveWeakSet)
61
- registerNativeReactivity(Set, ReactiveSet)
62
- registerNativeReactivity(Array, ReactiveArray)
55
+ metaProtos.set(Array, ReactiveArray.prototype)
56
+ metaProtos.set(Set, ReactiveSet.prototype)
57
+ metaProtos.set(WeakSet, ReactiveWeakSet.prototype)
58
+ metaProtos.set(Map, ReactiveMap.prototype)
59
+ metaProtos.set(WeakMap, ReactiveWeakMap.prototype)
63
60
 
64
61
  /**
65
62
  * Object containing internal reactive system state for debugging and profiling