mutts 1.0.4 → 1.0.6

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 (75) hide show
  1. package/README.md +2 -1
  2. package/dist/chunks/{_tslib-Mzh1rNsX.esm.js → _tslib-MCKDzsSq.esm.js} +2 -2
  3. package/dist/chunks/_tslib-MCKDzsSq.esm.js.map +1 -0
  4. package/dist/chunks/decorator-BGILvPtN.esm.js +627 -0
  5. package/dist/chunks/decorator-BGILvPtN.esm.js.map +1 -0
  6. package/dist/chunks/decorator-BQ2eBTCj.js +651 -0
  7. package/dist/chunks/decorator-BQ2eBTCj.js.map +1 -0
  8. package/dist/chunks/{index-GRBSx0mB.js → index-CDCOjzTy.js} +543 -495
  9. package/dist/chunks/index-CDCOjzTy.js.map +1 -0
  10. package/dist/chunks/{index-79Kk8D6e.esm.js → index-DiP0RXoZ.esm.js} +452 -404
  11. package/dist/chunks/index-DiP0RXoZ.esm.js.map +1 -0
  12. package/dist/decorator.d.ts +3 -3
  13. package/dist/decorator.esm.js +1 -1
  14. package/dist/decorator.js +1 -1
  15. package/dist/destroyable.esm.js +4 -4
  16. package/dist/destroyable.esm.js.map +1 -1
  17. package/dist/destroyable.js +4 -4
  18. package/dist/destroyable.js.map +1 -1
  19. package/dist/devtools/panel.js.map +1 -1
  20. package/dist/eventful.esm.js +1 -1
  21. package/dist/index.esm.js +48 -3
  22. package/dist/index.esm.js.map +1 -1
  23. package/dist/index.js +50 -4
  24. package/dist/index.js.map +1 -1
  25. package/dist/mutts.umd.js +1 -1
  26. package/dist/mutts.umd.js.map +1 -1
  27. package/dist/mutts.umd.min.js +1 -1
  28. package/dist/mutts.umd.min.js.map +1 -1
  29. package/dist/reactive.d.ts +54 -1
  30. package/dist/reactive.esm.js +3 -3
  31. package/dist/reactive.js +6 -4
  32. package/dist/reactive.js.map +1 -1
  33. package/dist/std-decorators.d.ts +1 -1
  34. package/dist/std-decorators.esm.js +10 -10
  35. package/dist/std-decorators.esm.js.map +1 -1
  36. package/dist/std-decorators.js +10 -10
  37. package/dist/std-decorators.js.map +1 -1
  38. package/docs/ai/manual.md +14 -95
  39. package/docs/reactive/advanced.md +6 -107
  40. package/docs/reactive/core.md +16 -16
  41. package/docs/reactive/debugging.md +158 -0
  42. package/docs/reactive.md +8 -0
  43. package/package.json +16 -66
  44. package/src/decorator.ts +11 -9
  45. package/src/destroyable.ts +5 -5
  46. package/src/index.ts +46 -0
  47. package/src/reactive/array.ts +3 -5
  48. package/src/reactive/change.ts +7 -3
  49. package/src/reactive/debug.ts +1 -1
  50. package/src/reactive/deep-touch.ts +1 -1
  51. package/src/reactive/deep-watch.ts +1 -1
  52. package/src/reactive/effect-context.ts +2 -2
  53. package/src/reactive/effects.ts +114 -17
  54. package/src/reactive/index.ts +3 -2
  55. package/src/reactive/interface.ts +10 -9
  56. package/src/reactive/map.ts +6 -6
  57. package/src/reactive/mapped.ts +2 -3
  58. package/src/reactive/memoize.ts +77 -31
  59. package/src/reactive/project.ts +103 -6
  60. package/src/reactive/proxy.ts +4 -4
  61. package/src/reactive/registry.ts +67 -0
  62. package/src/reactive/set.ts +6 -6
  63. package/src/reactive/tracking.ts +12 -41
  64. package/src/reactive/types.ts +59 -0
  65. package/src/reactive/zone.ts +1 -1
  66. package/src/std-decorators.ts +10 -10
  67. package/src/utils.ts +141 -0
  68. package/dist/chunks/_tslib-Mzh1rNsX.esm.js.map +0 -1
  69. package/dist/chunks/decorator-DLvrD0UF.js +0 -265
  70. package/dist/chunks/decorator-DLvrD0UF.js.map +0 -1
  71. package/dist/chunks/decorator-DqiszP7i.esm.js +0 -253
  72. package/dist/chunks/decorator-DqiszP7i.esm.js.map +0 -1
  73. package/dist/chunks/index-79Kk8D6e.esm.js.map +0 -1
  74. package/dist/chunks/index-GRBSx0mB.js.map +0 -1
  75. /package/{src/reactive/project.project.md → docs/reactive/project.md} +0 -0
@@ -1,9 +1,9 @@
1
1
  import { recordTriggerLink } from './debug'
2
2
  import { bubbleUpChange, objectsWithDeepWatchers } from './deep-watch-state'
3
3
  import { getActiveEffect, isRunning } from './effect-context'
4
- import { batch, effectTrackers, opaqueEffects } from './effects'
4
+ import { batch, effectTrackers, hasBatched, opaqueEffects, recordActivation } from './effects'
5
5
  import { unwrap } from './proxy-state'
6
- import { watchers } from './tracking'
6
+ import { watchers } from './registry'
7
7
  import { allProps, type Evolution, options, type ScopedCallback, type State } from './types'
8
8
 
9
9
  const states = new WeakMap<object, State>()
@@ -49,7 +49,10 @@ export function collectEffects(
49
49
  options.skipRunningEffect(effect, runningChain as any)
50
50
  continue
51
51
  }
52
- effects.add(effect)
52
+ if (!effects.has(effect)) {
53
+ effects.add(effect)
54
+ if (!hasBatched(effect)) recordActivation(effect, obj, evolution, key)
55
+ }
53
56
  const trackers = effectTrackers.get(effect)
54
57
  recordTriggerLink(sourceEffect, effect, obj, key, evolution)
55
58
  if (trackers) {
@@ -119,6 +122,7 @@ export function touchedOpaque(obj: any, evolution: Evolution, prop: any) {
119
122
  continue
120
123
  }
121
124
  effects.add(effect)
125
+ recordActivation(effect, obj, evolution, prop)
122
126
  const trackers = effectTrackers.get(effect)
123
127
  recordTriggerLink(sourceEffect, effect, obj, prop, evolution)
124
128
  if (trackers) {
@@ -5,7 +5,7 @@
5
5
  * - Provides graph data for tooling (DevTools panel, etc.)
6
6
  */
7
7
 
8
- import { effectParent, effectToReactiveObjects, getRoot } from './tracking'
8
+ import { effectParent, effectToReactiveObjects, getRoot } from './registry'
9
9
  import { allProps, type Evolution, options, type ScopedCallback } from './types'
10
10
 
11
11
  const EXTERNAL_SOURCE = Symbol('external-source')
@@ -3,7 +3,7 @@ import { bubbleUpChange, objectsWithDeepWatchers } from './deep-watch-state'
3
3
  import { batch } from './effects'
4
4
  import { isNonReactive } from './non-reactive-state'
5
5
  import { unwrap } from './proxy-state'
6
- import { effectParent, watchers } from './tracking'
6
+ import { effectParent, watchers } from './registry'
7
7
  import { allProps, type Evolution, options, type ScopedCallback } from './types'
8
8
 
9
9
  function isObject(value: any): value is object {
@@ -6,7 +6,7 @@ import {
6
6
  import { effect } from './effects'
7
7
  import { isNonReactive } from './non-reactive-state'
8
8
  import { reactive, unwrap } from './proxy'
9
- import { markWithRoot } from './tracking'
9
+ import { markWithRoot } from './registry'
10
10
  import { options, type ScopedCallback } from './types'
11
11
 
12
12
  function isObject(value: any): value is object {
@@ -1,4 +1,4 @@
1
- import { effectParent, getRoot } from './tracking'
1
+ import { effectParent, getRoot } from './registry'
2
2
  import { ReactiveError, type ScopedCallback } from './types'
3
3
 
4
4
  /**
@@ -77,7 +77,7 @@ export function getActiveEffect() {
77
77
  * @returns The result of the function
78
78
  */
79
79
  export function withEffect<T>(effect: ScopedCallback | undefined, fn: () => T): T {
80
- // console.log('[Mutts] withEffect', effect ? 'Active' : 'NULL');
80
+
81
81
  if (getRoot(effect) === getRoot(getActiveEffect())) return fn()
82
82
  stack.unshift(effect)
83
83
  try {
@@ -8,16 +8,18 @@ import {
8
8
  withEffect,
9
9
  withEffectStack,
10
10
  } from './effect-context'
11
+ import {
12
+ getTrackingDisabled,
13
+ setTrackingDisabled,
14
+ } from './tracking'
11
15
  import {
12
16
  effectChildren,
13
17
  effectParent,
14
18
  effectToReactiveObjects,
15
19
  getRoot,
16
- getTrackingDisabled,
17
20
  markWithRoot,
18
- setTrackingDisabled,
19
21
  watchers,
20
- } from './tracking'
22
+ } from './registry'
21
23
  import {
22
24
  type DependencyAccess,
23
25
  type EffectOptions,
@@ -27,6 +29,8 @@ import {
27
29
  ReactiveErrorCode,
28
30
  // type AsyncExecutionMode,
29
31
  type ScopedCallback,
32
+ cleanup as cleanupSymbol,
33
+ stopped,
30
34
  } from './types'
31
35
 
32
36
  /**
@@ -60,6 +64,69 @@ import { ensureZoneHooked } from './zone'
60
64
  type EffectTracking = (obj: any, evolution: Evolution, prop: any) => void
61
65
 
62
66
  export { captureEffectStack, withEffectStack, getActiveEffect, effectStack }
67
+ export interface ActivationRecord {
68
+ effect: ScopedCallback
69
+ obj: any
70
+ evolution: Evolution
71
+ prop: any
72
+ batchId: number
73
+ }
74
+
75
+ // Nested map structure for efficient counting and batch cleanup
76
+ // batchId -> effect root -> obj -> prop -> count
77
+ let activationRegistry: Map<Function, Map<any, Map<any, number>>> | undefined
78
+
79
+ export const activationLog: Omit<ActivationRecord, 'batchId'>[] = new Array(100)
80
+
81
+ export function getActivationLog() {
82
+ return activationLog
83
+ }
84
+
85
+ export function recordActivation(
86
+ effect: ScopedCallback,
87
+ obj: any,
88
+ evolution: Evolution,
89
+ prop: any
90
+ ) {
91
+ const root = getRoot(effect)
92
+
93
+ if (!activationRegistry) return
94
+ let effectData = activationRegistry.get(root)
95
+ if (!effectData) {
96
+ effectData = new Map()
97
+ activationRegistry.set(root, effectData)
98
+ }
99
+ let objData = effectData.get(obj)
100
+ if (!objData) {
101
+ objData = new Map()
102
+ effectData.set(obj, objData)
103
+ }
104
+ const count = (objData.get(prop) ?? 0) + 1
105
+ objData.set(prop, count)
106
+
107
+ // Keep a limited history for diagnostics
108
+ activationLog.unshift({
109
+ effect,
110
+ obj,
111
+ evolution,
112
+ prop,
113
+ })
114
+ activationLog.pop()
115
+
116
+ if (count >= options.maxTriggerPerBatch) {
117
+ const effectName = (root as any)?.name || 'anonymous'
118
+ const message = `Aggressive trigger detected: effect "${effectName}" triggered ${count} times in the batch by the same cause.`
119
+ if (options.maxEffectReaction === 'throw') {
120
+ throw new ReactiveError(message, {
121
+ code: ReactiveErrorCode.MaxReactionExceeded,
122
+ count,
123
+ effect: effectName,
124
+ })
125
+ }
126
+ options.warn(`[reactive] ${message}`)
127
+ }
128
+ }
129
+
63
130
  /**
64
131
  * Registers a debug callback that is called when the current effect is triggered by a dependency change
65
132
  *
@@ -234,6 +301,7 @@ function addGraphEdge(callerRoot: Function, targetRoot: Function) {
234
301
  * @param end - Target node
235
302
  * @param exclude - Node to exclude from the path
236
303
  * @returns true if a path exists without going through the excluded node
304
+ * @todo Can be REALLY costly - optimise or make optional or ...
237
305
  */
238
306
  function hasPathExcluding(start: Function, end: Function, exclude: Function): boolean {
239
307
  if (start === end) return true
@@ -380,6 +448,9 @@ interface BatchQueue {
380
448
  // Track currently executing effects to prevent re-execution
381
449
  // These are all the effects triggered under `activeEffect`
382
450
  let batchQueue: BatchQueue | undefined
451
+ export function hasBatched(effect: ScopedCallback) {
452
+ return batchQueue?.all.has(getRoot(effect))
453
+ }
383
454
  const batchCleanups = new Set<ScopedCallback>()
384
455
 
385
456
  /**
@@ -555,6 +626,11 @@ function wouldCreateCycle(callerRoot: Function, targetRoot: Function): boolean {
555
626
  * @param immediate - If true, don't create edges in the dependency graph
556
627
  */
557
628
  function addToBatch(effect: ScopedCallback, caller?: ScopedCallback, immediate?: boolean) {
629
+ const cleanupFn = (effect as any)[cleanupSymbol]
630
+ if (cleanupFn) cleanupFn()
631
+ // If the effect was stopped during cleanup (e.g. lazy memoization), don't add it to the batch
632
+ if ((effect as any)[stopped]) return
633
+
558
634
  if (!batchQueue) return
559
635
 
560
636
  const root = getRoot(effect)
@@ -851,6 +927,8 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
851
927
  // Otherwise, effects will be picked up in next executeNext() call
852
928
  } else {
853
929
  // New batch - initialize
930
+ if (!activationRegistry) activationRegistry = new Map()
931
+ else throw new Error('Batch already in progress')
854
932
  options.beginChain(roots)
855
933
  batchQueue = {
856
934
  all: new Map(),
@@ -931,6 +1009,7 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
931
1009
  for (const cleanup of cleanups) cleanup()
932
1010
  return firstReturn.value
933
1011
  } finally {
1012
+ activationRegistry = undefined
934
1013
  batchQueue = undefined
935
1014
  options.endChain()
936
1015
  }
@@ -1003,11 +1082,11 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
1003
1082
  }
1004
1083
  return firstReturn.value
1005
1084
  } finally {
1085
+ activationRegistry = undefined
1006
1086
  batchQueue = undefined
1007
1087
  options.endChain()
1008
1088
  }
1009
1089
  }
1010
-
1011
1090
  }
1012
1091
  }
1013
1092
 
@@ -1016,21 +1095,21 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
1016
1095
  */
1017
1096
  export const atomic = decorator({
1018
1097
  method(original) {
1019
- return function (...args: any[]) {
1020
- return batch(
1021
- markWithRoot(() => original.apply(this, args), original),
1022
- 'immediate'
1023
- )
1098
+ return function (this: any, ...args: any[]) {
1099
+ const atomicEffect = () => original.apply(this, args)
1100
+ // Debug: helpful to have a name
1101
+ Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1102
+ return batch(atomicEffect, 'immediate')
1024
1103
  }
1025
1104
  },
1026
1105
  default<Args extends any[], Return>(
1027
1106
  original: (...args: Args) => Return
1028
1107
  ): (...args: Args) => Return {
1029
1108
  return function (this: any, ...args: Args) {
1030
- return batch(
1031
- markWithRoot(() => original.apply(this, args), original),
1032
- 'immediate'
1033
- )
1109
+ const atomicEffect = () => original.apply(this, args)
1110
+ // Debug: helpful to have a name
1111
+ Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1112
+ return batch(atomicEffect, 'immediate')
1034
1113
  }
1035
1114
  },
1036
1115
  })
@@ -1069,7 +1148,7 @@ export function effect(
1069
1148
  let cleanup: (() => void) | null = null
1070
1149
  // capture the parent effect at creation time for ascend
1071
1150
  const parentsForAscend = captureEffectStack()
1072
- const tracked = markWithRoot(<T>(cb: () => T) => withEffect(runEffect, cb), fn)
1151
+ const tracked = <T>(cb: () => T) => withEffect(runEffect, cb)
1073
1152
  const ascend = <T>(cb: () => T) => withEffectStack(parentsForAscend, cb)
1074
1153
  let effectStopped = false
1075
1154
  let hasReacted = false
@@ -1213,8 +1292,25 @@ export function effect(
1213
1292
  cleanupEffectFromGraph(runEffect)
1214
1293
  fr.unregister(stopEffect)
1215
1294
  }
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
+ }
1216
1312
  if (isRootEffect) {
1217
- const callIfCollected = () => stopEffect()
1313
+ const callIfCollected = augmentedRv(() => stopEffect())
1218
1314
  fr.register(
1219
1315
  callIfCollected,
1220
1316
  () => {
@@ -1231,15 +1327,16 @@ export function effect(
1231
1327
  children = new Set()
1232
1328
  effectChildren.set(parent, children)
1233
1329
  }
1234
- const subEffectCleanup = (): void => {
1330
+ const subEffectCleanup = augmentedRv(() => {
1235
1331
  children.delete(subEffectCleanup)
1236
1332
  if (children.size === 0) {
1237
1333
  effectChildren.delete(parent)
1238
1334
  }
1239
1335
  // Execute this child effect cleanup (which triggers its own mainCleanup)
1240
1336
  stopEffect()
1241
- }
1337
+ })
1242
1338
  children.add(subEffectCleanup)
1339
+
1243
1340
  return subEffectCleanup
1244
1341
  }
1245
1342
 
@@ -17,6 +17,7 @@ export {
17
17
  biDi,
18
18
  defer,
19
19
  effect,
20
+ getActivationLog,
20
21
  getActiveEffect,
21
22
  root,
22
23
  trackEffect,
@@ -26,7 +27,7 @@ export { cleanedBy, cleanup, derived, unreactive, watch } from './interface'
26
27
  export { mapped, ReadOnlyError, reduced } from './mapped'
27
28
  export { type Memoizable, memoize } from './memoize'
28
29
  export { immutables, isNonReactive, registerNativeReactivity } from './non-reactive'
29
- export { project } from './project'
30
+ export { getActiveProjection, project } from './project'
30
31
  export { isReactive, ReactiveBase, reactive, unwrap } from './proxy'
31
32
  export { organize, organized } from './record'
32
33
  export { Register, register } from './register'
@@ -51,7 +52,7 @@ import { ReactiveMap, ReactiveWeakMap } from './map'
51
52
  import { nonReactiveObjects, registerNativeReactivity } from './non-reactive-state'
52
53
  import { objectToProxy, proxyToObject } from './proxy'
53
54
  import { ReactiveSet, ReactiveWeakSet } from './set'
54
- import { effectToReactiveObjects, watchers } from './tracking'
55
+ import { effectToReactiveObjects, watchers } from './registry'
55
56
 
56
57
  // Register native collection types to use specialized reactive wrappers
57
58
  registerNativeReactivity(WeakMap, ReactiveWeakMap)
@@ -1,10 +1,11 @@
1
- import { decorator, GenericClassDecorator } from '../decorator'
1
+ import { decorator, type GenericClassDecorator } from '../decorator'
2
2
  import { deepWatch } from './deep-watch'
3
3
  import { withEffect } from './effect-context'
4
4
  import { effect, getActiveEffect, untracked } from './effects'
5
5
  import { isNonReactive, nonReactiveClass, nonReactiveObjects } from './non-reactive-state'
6
6
  import { unwrap } from './proxy-state'
7
- import { dependant, markWithRoot } from './tracking'
7
+ import { dependant } from './tracking'
8
+ import { markWithRoot } from './registry'
8
9
  import {
9
10
  type DependencyAccess,
10
11
  nonReactiveMark,
@@ -89,11 +90,11 @@ function watchObject(
89
90
  const myParentEffect = getActiveEffect()
90
91
  if (deep) return deepWatch(value, changed, { immediate })!
91
92
  return effect(
92
- markWithRoot(function watchObjectEffect() {
93
+ function watchObjectEffect() {
93
94
  dependant(value)
94
95
  if (immediate) withEffect(myParentEffect, () => changed(value))
95
96
  immediate = true
96
- }, changed)
97
+ }
97
98
  )
98
99
  }
99
100
 
@@ -111,7 +112,7 @@ function watchCallBack<T>(
111
112
  if (oldValue !== newValue)
112
113
  withEffect(
113
114
  myParentEffect,
114
- markWithRoot(() => {
115
+ () => {
115
116
  if (oldValue === unsetYet) {
116
117
  if (immediate) changed(newValue)
117
118
  } else changed(newValue, oldValue)
@@ -120,10 +121,10 @@ function watchCallBack<T>(
120
121
  if (deepCleanup) deepCleanup()
121
122
  deepCleanup = deepWatch(
122
123
  newValue as object,
123
- markWithRoot((value) => changed(value as T, value as T), changed)
124
+ (value) => changed(value as T, value as T)
124
125
  )
125
126
  }
126
- }, changed)
127
+ }
127
128
  )
128
129
  }, value)
129
130
  )
@@ -214,9 +215,9 @@ export function derived<T>(compute: (dep: DependencyAccess) => T): {
214
215
  rv,
215
216
  untracked(() =>
216
217
  effect(
217
- markWithRoot(function derivedEffect(access) {
218
+ function derivedEffect(access) {
218
219
  rv.value = compute(access)
219
- }, compute)
220
+ }
220
221
  )
221
222
  )
222
223
  )
@@ -12,13 +12,13 @@ const native = Symbol('native')
12
12
  * Only tracks individual key operations, no size tracking (WeakMap limitation)
13
13
  */
14
14
  export class ReactiveWeakMap<K extends object, V> {
15
- declare readonly [native]: WeakMap<K, V>
16
- declare readonly content: symbol
15
+ readonly [native]!: WeakMap<K, V>
16
+ readonly content!: symbol
17
17
  constructor(original: WeakMap<K, V>) {
18
18
  Object.defineProperties(this, {
19
19
  [native]: { value: original },
20
20
  [prototypeForwarding]: { value: original },
21
- content: { value: Symbol('content') },
21
+ content: { value: Symbol('WeakMapContent') },
22
22
  [Symbol.toStringTag]: { value: 'ReactiveWeakMap' },
23
23
  })
24
24
  }
@@ -62,14 +62,14 @@ export class ReactiveWeakMap<K extends object, V> {
62
62
  * Tracks size changes, individual key operations, and collection-wide operations
63
63
  */
64
64
  export class ReactiveMap<K, V> {
65
- declare readonly [native]: Map<K, V>
66
- declare readonly content: symbol
65
+ readonly [native]!: Map<K, V>
66
+ readonly content!: symbol
67
67
 
68
68
  constructor(original: Map<K, V>) {
69
69
  Object.defineProperties(this, {
70
70
  [native]: { value: original },
71
71
  [prototypeForwarding]: { value: original },
72
- content: { value: Symbol('content') },
72
+ content: { value: Symbol('MapContent') },
73
73
  [Symbol.toStringTag]: { value: 'ReactiveMap' },
74
74
  })
75
75
  }
@@ -5,10 +5,10 @@ import { effect, untracked } from './effects'
5
5
  import { cleanedBy } from './interface'
6
6
  import { reactive } from './proxy'
7
7
  import { dependant } from './tracking'
8
- import { prototypeForwarding, ScopedCallback } from './types'
8
+ import { prototypeForwarding, type ScopedCallback } from './types'
9
9
 
10
10
  // TODO: Lazy reactivity ?
11
- export class ReadOnlyError extends Error {}
11
+ export class ReadOnlyError extends Error { }
12
12
  /**
13
13
  * Reactive wrapper around JavaScript's Array class with full array method support
14
14
  * Tracks length changes, individual index operations, and collection-wide operations
@@ -29,7 +29,6 @@ class ReactiveReadOnlyArrayClass extends Indexable(ReactiveBaseArray, {
29
29
  throw new ReadOnlyError(`Setting length to ${value} on a read-only array`)
30
30
  },
31
31
  }) {
32
- declare length: number
33
32
  constructor(original: any[]) {
34
33
  super()
35
34
  Object.defineProperties(this, {
@@ -1,8 +1,10 @@
1
1
  import { decorator } from '../decorator'
2
- import { renamed } from '../utils'
2
+ import { deepCompare, renamed } from '../utils'
3
3
  import { touched1 } from './change'
4
- import { effect, root } from './effects'
5
- import { dependant, getRoot, markWithRoot } from './tracking'
4
+ import { effect, root, untracked } from './effects'
5
+ import { dependant } from './tracking'
6
+ import { getRoot, markWithRoot } from './registry'
7
+ import { options, rootFunction } from './types'
6
8
 
7
9
  export type Memoizable = object | any[] | symbol | ((...args: any[]) => any)
8
10
 
@@ -12,7 +14,8 @@ type MemoCacheTree<Result> = {
12
14
  branches?: WeakMap<Memoizable, MemoCacheTree<Result>>
13
15
  }
14
16
 
15
- const memoizedRegistry = new WeakMap<Function, Function>()
17
+ const memoizedRegistry = new WeakMap<any, Function>()
18
+ const wrapperRegistry = new WeakMap<Function, Function>()
16
19
 
17
20
  function getBranch<Result>(tree: MemoCacheTree<Result>, key: Memoizable): MemoCacheTree<Result> {
18
21
  tree.branches ??= new WeakMap()
@@ -38,18 +41,33 @@ function memoizeFunction<Result, Args extends Memoizable[]>(
38
41
  throw new Error('memoize expects non-null object arguments')
39
42
 
40
43
  let node: MemoCacheTree<Result> = cacheRoot
44
+ // Note: decorators add `this` as first argument
41
45
  for (const arg of localArgs) {
42
46
  node = getBranch(node, arg)
43
47
  }
44
48
 
45
49
  dependant(node, 'memoize')
46
- if ('result' in node) return node.result!
50
+ if ('result' in node) {
51
+ if (options.onMemoizationDiscrepancy) {
52
+ const wasVerification = options.isVerificationRun
53
+ options.isVerificationRun = true
54
+ try {
55
+ const fresh = untracked(() => fn(...localArgs))
56
+ if (!deepCompare(node.result, fresh)) {
57
+ options.onMemoizationDiscrepancy(node.result, fresh, fn, localArgs, 'calculation')
58
+ }
59
+ } finally {
60
+ options.isVerificationRun = wasVerification
61
+ }
62
+ }
63
+ return node.result!
64
+ }
47
65
 
48
66
  // Create memoize internal effect to track dependencies and invalidate cache
49
67
  // Use untracked to prevent the effect creation from being affected by parent effects
50
68
  node.cleanup = root(() =>
51
69
  effect(
52
- markWithRoot(() => {
70
+ () => {
53
71
  // Execute the function and track its dependencies
54
72
  // The function execution will automatically track dependencies on reactive objects
55
73
  node.result = fn(...localArgs)
@@ -57,11 +75,31 @@ function memoizeFunction<Result, Args extends Memoizable[]>(
57
75
  // When dependencies change, clear the cache and notify consumers
58
76
  delete node.result
59
77
  touched1(node, { type: 'invalidate', prop: localArgs }, 'memoize')
78
+ // Lazy memoization: stop the effect so it doesn't re-run immediately.
79
+ // It will be re-created on next access.
80
+ if (node.cleanup) {
81
+ node.cleanup()
82
+ node.cleanup = undefined
83
+ }
60
84
  }
61
- }, fnRoot),
85
+ },
62
86
  { opaque: true }
63
87
  )
64
88
  )
89
+
90
+ if (options.onMemoizationDiscrepancy) {
91
+ const wasVerification = options.isVerificationRun
92
+ options.isVerificationRun = true
93
+ try {
94
+ const fresh = untracked(() => fn(...localArgs))
95
+ if (!deepCompare(node.result, fresh)) {
96
+ options.onMemoizationDiscrepancy(node.result, fresh, fn, localArgs, 'comparison')
97
+ }
98
+ } finally {
99
+ options.isVerificationRun = wasVerification
100
+ }
101
+ }
102
+
65
103
  return node.result!
66
104
  }, fn)
67
105
 
@@ -71,35 +109,43 @@ function memoizeFunction<Result, Args extends Memoizable[]>(
71
109
  }
72
110
 
73
111
  export const memoize = decorator({
74
- getter(original, propertyKey) {
75
- const memoized = memoizeFunction(
76
- markWithRoot(
77
- renamed(
78
- (that: object) => {
79
- return original.call(that)
80
- },
81
- `${String(this.constructor.name)}.${String(propertyKey)}`
82
- ),
83
- original
84
- )
85
- )
112
+ getter(original, target, propertyKey) {
86
113
  return function (this: any) {
114
+ let wrapper = wrapperRegistry.get(original)
115
+ if (!wrapper) {
116
+ wrapper = markWithRoot(
117
+ renamed((that: object) => {
118
+ return original.call(that)
119
+ }, `${String(target?.constructor?.name ?? target?.name ?? 'Object')}.${String(propertyKey)}`),
120
+ {
121
+ method: original,
122
+ propertyKey,
123
+ ...((original as any)[rootFunction] ? { [rootFunction]: (original as any)[rootFunction] } : {}),
124
+ }
125
+ )
126
+ wrapperRegistry.set(original, wrapper)
127
+ }
128
+ const memoized = memoizeFunction(wrapper as any)
87
129
  return memoized(this)
88
130
  }
89
131
  },
90
- method(original, name) {
91
- const memoized = memoizeFunction(
92
- markWithRoot(
93
- renamed(
94
- (that: object, ...args: object[]) => {
95
- return original.call(that, ...args)
96
- },
97
- `${String(this.constructor.name)}.${String(name)}`
98
- ),
99
- original
100
- )
101
- ) as (...args: object[]) => unknown
132
+ method(original, target, name) {
102
133
  return function (this: any, ...args: object[]) {
134
+ let wrapper = wrapperRegistry.get(original)
135
+ if (!wrapper) {
136
+ wrapper = markWithRoot(
137
+ renamed((that: object, ...args: object[]) => {
138
+ return original.call(that, ...args)
139
+ }, `${String(target?.constructor?.name ?? target?.name ?? 'Object')}.${String(name)}`),
140
+ {
141
+ method: original,
142
+ propertyKey: name,
143
+ ...((original as any)[rootFunction] ? { [rootFunction]: (original as any)[rootFunction] } : {}),
144
+ }
145
+ )
146
+ wrapperRegistry.set(original, wrapper)
147
+ }
148
+ const memoized = memoizeFunction(wrapper as any) as (...args: object[]) => unknown
103
149
  return memoized(this, ...args)
104
150
  }
105
151
  },