mobx-keystone 1.25.0 → 2.1.0

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 (141) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/dist/mobx-keystone.esm.js +3627 -1540
  3. package/dist/mobx-keystone.esm.mjs +3627 -1540
  4. package/dist/mobx-keystone.umd.js +3638 -1543
  5. package/dist/types/actionMiddlewares/actionSerialization/dateSerializer.d.ts +1 -1
  6. package/dist/types/actionMiddlewares/transactionMiddleware.d.ts +1 -1
  7. package/dist/types/actionMiddlewares/undoMiddleware.d.ts +17 -10
  8. package/dist/types/dataModel/dataModelInstanceCache.d.ts +1 -0
  9. package/dist/types/deepChange/onDeepChange.d.ts +9 -0
  10. package/dist/types/parent/arrayPaths.d.ts +1 -0
  11. package/dist/types/parent/detach.d.ts +1 -1
  12. package/dist/types/parent/findChildren.d.ts +1 -1
  13. package/dist/types/parent/treeOrder.d.ts +1 -0
  14. package/dist/types/patch/applyPatches.d.ts +3 -0
  15. package/dist/types/redux/connectReduxDevTools.d.ts +2 -1
  16. package/dist/types/ref/modelRefIdIndex.d.ts +1 -0
  17. package/dist/types/ref/nodesById.d.ts +1 -0
  18. package/dist/types/ref/refIdIndex.d.ts +1 -0
  19. package/dist/types/snapshot/SnapshotOf.d.ts +2 -2
  20. package/dist/types/snapshot/applySnapshot.d.ts +1 -0
  21. package/dist/types/snapshot/fromSnapshot.d.ts +0 -3
  22. package/dist/types/standardActions/arrayActions.d.ts +1 -1
  23. package/dist/types/treeUtils/sandbox.d.ts +0 -4
  24. package/dist/types/tweaker/changeRollback.d.ts +1 -0
  25. package/dist/types/tweaker/tweakedChangeListeners.d.ts +1 -0
  26. package/dist/types/types/TypeCheckError.d.ts +1 -0
  27. package/dist/types/types/utility/typesRefinement.d.ts +2 -2
  28. package/dist/types/utils/ModelPool.d.ts +7 -1
  29. package/dist/types/utils/chunks.d.ts +1 -0
  30. package/dist/types/utils/forEachWithDelayedThrow.d.ts +1 -0
  31. package/dist/types/utils/index.d.ts +13 -0
  32. package/dist/types/utils/mapUtils.d.ts +2 -2
  33. package/dist/types/utils/setLike.d.ts +1 -0
  34. package/package.json +2 -2
  35. package/src/action/applyAction.ts +1 -1
  36. package/src/action/middleware.ts +17 -36
  37. package/src/action/modelFlow.ts +4 -5
  38. package/src/action/mutationBatch.ts +10 -10
  39. package/src/action/pendingActions.ts +3 -4
  40. package/src/actionMiddlewares/actionSerialization/actionSerialization.ts +19 -14
  41. package/src/actionMiddlewares/actionSerialization/applySerializedAction.ts +29 -20
  42. package/src/actionMiddlewares/actionSerialization/arraySerializer.ts +1 -1
  43. package/src/actionMiddlewares/actionSerialization/dateSerializer.ts +4 -3
  44. package/src/actionMiddlewares/actionSerialization/mapSerializer.ts +4 -12
  45. package/src/actionMiddlewares/actionSerialization/setSerializer.ts +4 -13
  46. package/src/actionMiddlewares/actionTrackingMiddleware.ts +11 -13
  47. package/src/actionMiddlewares/transactionMiddleware.ts +55 -25
  48. package/src/actionMiddlewares/undoMiddleware.ts +112 -59
  49. package/src/actionMiddlewares/utils.ts +2 -1
  50. package/src/computedTree/computedTree.ts +14 -3
  51. package/src/context/context.ts +17 -45
  52. package/src/dataModel/BaseDataModel.ts +20 -16
  53. package/src/dataModel/dataModelInstanceCache.ts +8 -0
  54. package/src/deepChange/onDeepChange.ts +179 -24
  55. package/src/frozen/Frozen.ts +50 -33
  56. package/src/model/BaseModel.ts +10 -13
  57. package/src/model/newModel.ts +21 -24
  58. package/src/model/utils.ts +2 -2
  59. package/src/modelShared/modelClassInitializer.ts +3 -2
  60. package/src/modelShared/modelDecorator.ts +39 -39
  61. package/src/modelShared/modelInfo.ts +1 -1
  62. package/src/modelShared/prop.ts +6 -9
  63. package/src/modelShared/sharedInternalModel.ts +16 -3
  64. package/src/parent/arrayPaths.ts +242 -0
  65. package/src/parent/coreObjectChildren.ts +300 -48
  66. package/src/parent/detach.ts +1 -1
  67. package/src/parent/findChildren.ts +4 -7
  68. package/src/parent/onChildAttachedTo.ts +157 -55
  69. package/src/parent/path.ts +15 -5
  70. package/src/parent/setParent.ts +34 -15
  71. package/src/parent/treeOrder.ts +53 -0
  72. package/src/parent/walkTree.ts +12 -101
  73. package/src/patch/applyPatches.ts +74 -34
  74. package/src/patch/emitPatch.ts +142 -26
  75. package/src/patch/jsonPatch.ts +5 -0
  76. package/src/patch/patchRecorder.ts +18 -14
  77. package/src/redux/connectReduxDevTools.ts +24 -18
  78. package/src/redux/redux.ts +5 -21
  79. package/src/ref/core.ts +81 -29
  80. package/src/ref/modelRefIdIndex.ts +131 -0
  81. package/src/ref/nodesById.ts +60 -0
  82. package/src/ref/refIdIndex.ts +209 -0
  83. package/src/rootStore/attachDetach.ts +27 -16
  84. package/src/rootStore/rootStore.ts +10 -6
  85. package/src/snapshot/SnapshotOf.ts +2 -2
  86. package/src/snapshot/applySnapshot.ts +27 -19
  87. package/src/snapshot/clone.ts +1 -2
  88. package/src/snapshot/cloneTreeValue.ts +1 -2
  89. package/src/snapshot/fromModelSnapshot.ts +6 -2
  90. package/src/snapshot/fromPlainObjectSnapshot.ts +3 -8
  91. package/src/snapshot/fromSnapshot.ts +2 -6
  92. package/src/snapshot/internal.ts +89 -19
  93. package/src/snapshot/onSnapshot.ts +12 -1
  94. package/src/snapshot/reconcileModelSnapshot.ts +6 -6
  95. package/src/snapshot/reconcilePlainObjectSnapshot.ts +5 -4
  96. package/src/standardActions/arrayActions.ts +12 -2
  97. package/src/standardActions/objectActions.ts +3 -2
  98. package/src/transforms/ImmutableDate.ts +4 -0
  99. package/src/transforms/asMap.ts +3 -1
  100. package/src/treeUtils/deepEquals.ts +15 -13
  101. package/src/treeUtils/draft.ts +8 -0
  102. package/src/treeUtils/sandbox.ts +31 -27
  103. package/src/tweaker/changeRollback.ts +218 -0
  104. package/src/tweaker/core.ts +33 -22
  105. package/src/tweaker/treeNodeMetadata.ts +12 -2
  106. package/src/tweaker/tweak.ts +183 -19
  107. package/src/tweaker/tweakArray.ts +176 -33
  108. package/src/tweaker/tweakPlainObject.ts +117 -39
  109. package/src/tweaker/tweakedChangeListeners.ts +61 -0
  110. package/src/tweaker/typeChecking.ts +25 -15
  111. package/src/types/TypeCheckError.ts +17 -19
  112. package/src/types/arrayBased/typesArray.ts +10 -11
  113. package/src/types/createCachedTypeCheck.ts +541 -88
  114. package/src/types/objectBased/typesDataModelData.ts +4 -2
  115. package/src/types/objectBased/typesModel.ts +4 -2
  116. package/src/types/objectBased/typesObject.ts +20 -16
  117. package/src/types/objectBased/typesRecord.ts +8 -18
  118. package/src/types/objectBased/typesRef.ts +2 -2
  119. package/src/types/primitiveBased/typesEnum.ts +4 -2
  120. package/src/types/primitiveBased/typesPrimitive.ts +9 -4
  121. package/src/types/typeCheckErrorUtils.ts +1 -0
  122. package/src/types/utility/typesCodecSupport.ts +97 -56
  123. package/src/types/utility/typesMap.ts +24 -41
  124. package/src/types/utility/typesRefinement.ts +3 -2
  125. package/src/types/utility/typesSet.ts +24 -0
  126. package/src/utils/ModelPool.ts +78 -22
  127. package/src/utils/chainFns.ts +4 -0
  128. package/src/utils/chunks.ts +271 -0
  129. package/src/utils/decorators.ts +1 -1
  130. package/src/utils/errorDiagnostics.ts +6 -2
  131. package/src/utils/forEachWithDelayedThrow.ts +83 -0
  132. package/src/utils/index.ts +71 -2
  133. package/src/utils/mapUtils.ts +3 -7
  134. package/src/utils/setIfDifferent.ts +3 -2
  135. package/src/utils/setLike.ts +134 -0
  136. package/src/wrappers/ArraySet.ts +5 -9
  137. package/src/wrappers/ObjectMap.ts +15 -11
  138. package/src/wrappers/asMap.ts +30 -18
  139. package/src/wrappers/asSet.ts +36 -12
  140. package/dist/types/action/modelFlowPromiseGenerator.d.ts +0 -1
  141. package/src/action/modelFlowPromiseGenerator.ts +0 -127
package/src/ref/core.ts CHANGED
@@ -2,16 +2,14 @@ import { action, type ObservableSet, observable, reaction, when } from "mobx"
2
2
  import { isModel } from "../model/utils"
3
3
  import type { ModelClass } from "../modelShared/BaseModelShared"
4
4
  import { model } from "../modelShared/modelDecorator"
5
- import {
6
- getDeepObjectChildren,
7
- registerDeepObjectChildrenExtension,
8
- } from "../parent/coreObjectChildren"
5
+ import { registerDeepObjectChildrenExtension } from "../parent/coreObjectChildren"
9
6
  import { fastGetRoot } from "../parent/path"
10
- import { type ComputedWalkTreeAggregate, computedWalkTreeAggregate } from "../parent/walkTree"
11
7
  import { assertTweakedObject } from "../tweaker/core"
12
8
  import { assertIsObject, failure, mobxAction } from "../utils"
13
9
  import { getOrCreate } from "../utils/mapUtils"
10
+ import { modelRefIdNotIndexed, resolveModelRefId } from "./modelRefIdIndex"
14
11
  import { Ref, type RefConstructor } from "./Ref"
12
+ import { resolveRefIdByIndex } from "./refIdIndex"
15
13
 
16
14
  interface BackRefs<T extends object> {
17
15
  all: ObservableSet<Ref<T>>
@@ -88,10 +86,26 @@ export function internalCustomRef<T extends object>(
88
86
  trackedRefs.add(this as Ref<object>)
89
87
  this.internalForceUpdateBackRefs(initialTarget)
90
88
 
89
+ // MobX 4/5 do not pass the previous value to reaction effects.
90
+ // The reaction is usually created inside an action, so its first tracking run is deferred
91
+ // to the end of the batch and `initialTarget` may be stale by then; capture the value of
92
+ // that first run instead, since that is the one the effect will be compared against.
93
+ let previousTarget = initialTarget
94
+ let firstTrackingRun = true
91
95
  // according to mwestrate this won't leak as long as we don't keep the disposer around
92
96
  reaction(
93
- () => this.maybeCurrent,
94
- (newTarget, oldTarget) => {
97
+ () => {
98
+ const target = this.maybeCurrent
99
+ if (firstTrackingRun) {
100
+ firstTrackingRun = false
101
+ previousTarget = target
102
+ this.internalForceUpdateBackRefs(target)
103
+ }
104
+ return target
105
+ },
106
+ (newTarget) => {
107
+ const oldTarget = previousTarget
108
+ previousTarget = newTarget
95
109
  this.internalForceUpdateBackRefs(newTarget)
96
110
 
97
111
  if (onResolvedValueChange && newTarget !== oldTarget) {
@@ -176,12 +190,6 @@ export function getModelRefId(target: object): string | undefined {
176
190
  return undefined
177
191
  }
178
192
 
179
- // one computed id tree per id function
180
- const computedIdTrees = new WeakMap<
181
- (node: object) => string | undefined,
182
- ComputedWalkTreeAggregate<string>
183
- >()
184
-
185
193
  /**
186
194
  * Resolves a node given its ID.
187
195
  *
@@ -196,13 +204,20 @@ export function resolveId<T extends object>(
196
204
  id: string,
197
205
  getId: RefIdResolver = getModelRefId
198
206
  ): T | undefined {
199
- // cache/reuse computedIdTrees for same getId function
200
- const computedIdTree = getOrCreate(computedIdTrees, getId, () =>
201
- computedWalkTreeAggregate<string>((node) => getId(node))
202
- )
207
+ // parent resolutions have priority over child ones
208
+ if (getId(root) === id) {
209
+ return root as T
210
+ }
203
211
 
204
- const idMap = computedIdTree.walk(root)
205
- return idMap ? (idMap.get(id) as T | undefined) : undefined
212
+ if (getId === getModelRefId) {
213
+ const found = resolveModelRefId(root, id)
214
+ if (found !== modelRefIdNotIndexed) {
215
+ return found as T | undefined
216
+ }
217
+ }
218
+
219
+ // custom ids (or models with a custom `getRefId`)
220
+ return resolveRefIdByIndex(root, id, getId) as T | undefined
206
221
  }
207
222
 
208
223
  function getBackRefs<T extends object>(
@@ -269,14 +284,25 @@ export function getRefsResolvingTo<T extends object>(
269
284
  oldBackRefs.forEach(updateRef)
270
285
  }
271
286
 
272
- const refsChildrenOfRoot = getDeepChildrenRefs(getDeepObjectChildren(fastGetRoot(target, true)))
273
- let refs: Set<Ref<object>> | undefined
274
- if (refType) {
275
- refs = refsChildrenOfRoot.byType.get(refType.refClass)
287
+ const refsChildrenOfRoot = getDeepChildrenRefs.get(fastGetRoot(target, true))
288
+ if (shouldForceUpdateTrackedRefs) {
289
+ const refs = refType ? refsChildrenOfRoot.byType.get(refType.refClass) : refsChildrenOfRoot.all
290
+ refs?.forEach(updateRef)
276
291
  } else {
277
- refs = refsChildrenOfRoot.all
292
+ // tracked refs keep their back-references up to date by themselves
293
+ const untrackedRefs = refType
294
+ ? refsChildrenOfRoot.untrackedByType.get(refType.refClass)
295
+ : refsChildrenOfRoot.untracked
296
+ if (untrackedRefs && untrackedRefs.size > 0) {
297
+ // copy, since updating back-references might change the tree
298
+ for (const ref of Array.from(untrackedRefs)) {
299
+ updateRef(ref)
300
+ if (trackedRefs.has(ref)) {
301
+ deleteRef(refsChildrenOfRoot.untracked, refsChildrenOfRoot.untrackedByType, ref)
302
+ }
303
+ }
304
+ }
278
305
  }
279
- refs?.forEach(updateRef)
280
306
 
281
307
  return getBackRefs(target, refType)
282
308
  }
@@ -316,9 +342,24 @@ function isReactionDelayed() {
316
342
  return reactionDelayed
317
343
  }
318
344
 
345
+ type RefsByType = WeakMap<ModelClass<Ref<any>>, Set<Ref<any>>>
346
+
319
347
  interface DeepChildrenRefs {
320
348
  all: Set<Ref<any>>
321
- byType: WeakMap<ModelClass<Ref<any>>, Set<Ref<any>>>
349
+ byType: RefsByType
350
+ /** Refs whose back-references might not be tracked yet. */
351
+ untracked: Set<Ref<any>>
352
+ untrackedByType: RefsByType
353
+ }
354
+
355
+ function addRef(all: Set<Ref<any>>, byType: RefsByType, ref: Ref<any>) {
356
+ all.add(ref)
357
+ getOrCreate(byType, ref.constructor as ModelClass<Ref<any>>, () => new Set()).add(ref)
358
+ }
359
+
360
+ function deleteRef(all: Set<Ref<any>>, byType: RefsByType, ref: Ref<any>) {
361
+ all.delete(ref)
362
+ byType.get(ref.constructor as ModelClass<Ref<any>>)?.delete(ref)
322
363
  }
323
364
 
324
365
  const getDeepChildrenRefs = registerDeepObjectChildrenExtension<DeepChildrenRefs>({
@@ -326,14 +367,25 @@ const getDeepChildrenRefs = registerDeepObjectChildrenExtension<DeepChildrenRefs
326
367
  return {
327
368
  all: new Set(),
328
369
  byType: new WeakMap(),
370
+ untracked: new Set(),
371
+ untrackedByType: new WeakMap(),
329
372
  }
330
373
  },
331
374
 
332
375
  addNode(node, data) {
333
376
  if (node instanceof Ref) {
334
- data.all.add(node)
335
- const refsByThisType = getOrCreate(data.byType, node.constructor, () => new Set())
336
- refsByThisType.add(node)
377
+ addRef(data.all, data.byType, node)
378
+ if (!trackedRefs.has(node)) {
379
+ addRef(data.untracked, data.untrackedByType, node)
380
+ }
381
+ }
382
+ },
383
+
384
+ removeNode(node, data) {
385
+ if (node instanceof Ref) {
386
+ deleteRef(data.all, data.byType, node)
387
+ deleteRef(data.untracked, data.untrackedByType, node)
337
388
  }
389
+ return true
338
390
  },
339
391
  })
@@ -0,0 +1,131 @@
1
+ import { createAtom, type IAtom } from "mobx"
2
+ import { type AnyModel, BaseModel } from "../model/BaseModel"
3
+ import { modelIdKey } from "../model/metadata"
4
+ import { registerDeepObjectChildrenExtension } from "../parent/coreObjectChildren"
5
+ import { NodesById } from "./nodesById"
6
+
7
+ interface ModelRefIdIndex {
8
+ readonly byId: NodesById<AnyModel>
9
+ /** Id each model is indexed under, since ids can change later. */
10
+ readonly indexedIds: Map<AnyModel, string>
11
+ /** Models with a custom `getRefId`, which cannot be indexed. */
12
+ customRefIdModels: number
13
+ /** Changed when models with a custom `getRefId` appear or disappear. */
14
+ readonly customRefIdAtom: IAtom
15
+ /** Changed when the models with a given id change. Only kept while observed. */
16
+ readonly idAtoms: Map<string, IAtom>
17
+ }
18
+
19
+ function hasCustomRefId(model: AnyModel) {
20
+ return model.getRefId !== BaseModel.prototype.getRefId
21
+ }
22
+
23
+ function reportIdChanged(index: ModelRefIdIndex, id: string) {
24
+ index.idAtoms.get(id)?.reportChanged()
25
+ }
26
+
27
+ const modelRefIdIndex = registerDeepObjectChildrenExtension<ModelRefIdIndex>({
28
+ initData() {
29
+ return {
30
+ byId: new NodesById(),
31
+ indexedIds: new Map(),
32
+ customRefIdModels: 0,
33
+ customRefIdAtom: createAtom("customRefIdModels"),
34
+ idAtoms: new Map(),
35
+ }
36
+ },
37
+
38
+ addNode(node, index) {
39
+ if (!(node instanceof BaseModel)) {
40
+ return
41
+ }
42
+ if (hasCustomRefId(node)) {
43
+ index.customRefIdModels++
44
+ if (index.customRefIdModels === 1) {
45
+ index.customRefIdAtom.reportChanged()
46
+ }
47
+ return
48
+ }
49
+
50
+ const id = node[modelIdKey]
51
+ if (id === undefined) {
52
+ return
53
+ }
54
+ index.byId.add(id, node)
55
+ index.indexedIds.set(node, id)
56
+ reportIdChanged(index, id)
57
+ },
58
+
59
+ removeNode(node, index) {
60
+ if (!(node instanceof BaseModel)) {
61
+ return true
62
+ }
63
+ if (hasCustomRefId(node)) {
64
+ index.customRefIdModels--
65
+ if (index.customRefIdModels === 0) {
66
+ index.customRefIdAtom.reportChanged()
67
+ }
68
+ return true
69
+ }
70
+
71
+ const id = index.indexedIds.get(node)
72
+ if (id === undefined) {
73
+ return true
74
+ }
75
+ index.indexedIds.delete(node)
76
+ index.byId.delete(id, node)
77
+ reportIdChanged(index, id)
78
+ return true
79
+ },
80
+
81
+ dependsOnModelIds: true,
82
+ })
83
+
84
+ function observeId(index: ModelRefIdIndex, id: string) {
85
+ const existing = index.idAtoms.get(id)
86
+ if (existing) {
87
+ existing.reportObserved()
88
+ return
89
+ }
90
+ const atom = createAtom("modelRefId", undefined, () => {
91
+ if (index.idAtoms.get(id) === atom) {
92
+ index.idAtoms.delete(id)
93
+ }
94
+ })
95
+ // only keep atoms something observes, since nothing else would remove them
96
+ if (atom.reportObserved()) {
97
+ index.idAtoms.set(id, atom)
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Result of `resolveModelRefId` when the tree has models with a custom
103
+ * `getRefId`, which it cannot index.
104
+ *
105
+ * @internal
106
+ */
107
+ export const modelRefIdNotIndexed = Symbol("modelRefIdNotIndexed")
108
+
109
+ /**
110
+ * Resolves a model below the root (excluding it) by its default reference id
111
+ * (`getModelRefId`), reactively, in O(1), or O(models × depth) when several
112
+ * models have that id.
113
+ * Returns `modelRefIdNotIndexed` when the tree has models with a custom
114
+ * `getRefId`.
115
+ *
116
+ * @internal
117
+ */
118
+ export function resolveModelRefId(
119
+ root: object,
120
+ id: string
121
+ ): object | undefined | typeof modelRefIdNotIndexed {
122
+ const index = modelRefIdIndex.get(root, false)
123
+
124
+ index.customRefIdAtom.reportObserved()
125
+ if (index.customRefIdModels > 0) {
126
+ return modelRefIdNotIndexed
127
+ }
128
+
129
+ observeId(index, id)
130
+ return index.byId.resolve(root, id)
131
+ }
@@ -0,0 +1,60 @@
1
+ import { compareTreePaths, getPathFromAncestor } from "../parent/treeOrder"
2
+
3
+ /**
4
+ * The nodes below a root indexed by id, where several nodes may share an id.
5
+ *
6
+ * @internal
7
+ */
8
+ export class NodesById<T extends object> {
9
+ // (tree nodes are never sets)
10
+ private readonly byId = new Map<string, T | Set<T>>()
11
+
12
+ add(id: string, node: T): void {
13
+ const existing = this.byId.get(id)
14
+ if (existing === undefined) {
15
+ this.byId.set(id, node)
16
+ } else if (existing instanceof Set) {
17
+ existing.add(node)
18
+ } else {
19
+ this.byId.set(id, new Set([existing, node]))
20
+ }
21
+ }
22
+
23
+ delete(id: string, node: T): void {
24
+ const existing = this.byId.get(id)
25
+ if (existing instanceof Set) {
26
+ existing.delete(node)
27
+ if (existing.size === 1) {
28
+ this.byId.set(id, existing.values().next().value!)
29
+ }
30
+ } else if (existing === node) {
31
+ this.byId.delete(id)
32
+ }
33
+ }
34
+
35
+ /** Resolves the node with the id, picking one when several share it. */
36
+ resolve(root: object, id: string): T | undefined {
37
+ const found = this.byId.get(id)
38
+ return found instanceof Set ? resolveDuplicatedId(root, found) : found
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Picks which of several nodes below the root sharing an id it resolves to:
44
+ * an ancestor wins over its descendants, and otherwise the node below the
45
+ * later attached child of their closest common ancestor wins. That is the last
46
+ * one a post-order walk of the tree would find. Reactive, in
47
+ * O(nodes × depth).
48
+ */
49
+ function resolveDuplicatedId<T extends object>(root: object, nodes: Iterable<T>): T {
50
+ let winner: T | undefined
51
+ let winnerPath: object[] | undefined
52
+ for (const node of nodes) {
53
+ const path = getPathFromAncestor(root, node, true)!
54
+ if (!winnerPath || compareTreePaths(path, winnerPath, false) > 0) {
55
+ winner = node
56
+ winnerPath = path
57
+ }
58
+ }
59
+ return winner!
60
+ }
@@ -0,0 +1,209 @@
1
+ import { computed, createAtom, type IComputedValue, Reaction } from "mobx"
2
+ import {
3
+ type DeepObjectChildrenExtensionAccessor,
4
+ registerDeepObjectChildrenExtension,
5
+ } from "../parent/coreObjectChildren"
6
+ import { ChunkedSet, type SequenceChunk } from "../utils/chunks"
7
+ import { getOrCreate } from "../utils/mapUtils"
8
+ import { NodesById } from "./nodesById"
9
+
10
+ type GetId = (target: object) => string | undefined
11
+
12
+ /**
13
+ * A group of nodes, with a computed map of their current ids, so an id change
14
+ * only re-reads the ids of the nodes in its chunk.
15
+ */
16
+ class IdChunk implements SequenceChunk<object> {
17
+ items: object[]
18
+ /** Changed when nodes are added or removed. */
19
+ readonly atom = createAtom("refIdChunk")
20
+ readonly ids: IComputedValue<Map<string, object | object[]>>
21
+ /** Ids last copied to the index. */
22
+ syncedIds: Map<string, object | object[]> | undefined
23
+
24
+ constructor(nodes: object[], getId: GetId) {
25
+ this.items = nodes
26
+ this.ids = computed(() => {
27
+ this.atom.reportObserved()
28
+ const ids = new Map<string, object | object[]>()
29
+ for (const node of this.items) {
30
+ const id = getId(node)
31
+ if (id === undefined) {
32
+ continue
33
+ }
34
+ const existing = ids.get(id)
35
+ if (existing === undefined) {
36
+ ids.set(id, node)
37
+ } else if (Array.isArray(existing)) {
38
+ existing.push(node)
39
+ } else {
40
+ ids.set(id, [existing, node])
41
+ }
42
+ }
43
+ return ids
44
+ })
45
+ }
46
+ }
47
+
48
+ class RefIdIndex extends ChunkedSet<object, IdChunk> {
49
+ /** Changed when chunks are added or removed. */
50
+ readonly chunksAtom = createAtom("refIdChunks")
51
+ readonly byId = new NodesById<object>()
52
+ /** Copies the chunks' id changes into `byId`. Changes when `byId` does. */
53
+ readonly synced: IComputedValue<number>
54
+ /** Set when `byId` changed outside of a sync, so the version must change. */
55
+ private changedOutsideSync = false
56
+ keeper: Reaction | undefined
57
+ private readonly getId: GetId
58
+
59
+ constructor(getId: GetId) {
60
+ super()
61
+ this.getId = getId
62
+ let version = 0
63
+ this.synced = computed(
64
+ () => {
65
+ this.chunksAtom.reportObserved()
66
+ let changed = this.changedOutsideSync
67
+ this.changedOutsideSync = false
68
+ for (const chunk of this.chunks) {
69
+ if (this.syncChunk(chunk)) {
70
+ changed = true
71
+ }
72
+ }
73
+ if (changed) {
74
+ version++
75
+ }
76
+ return version
77
+ },
78
+ { name: "refIdIndex" }
79
+ )
80
+ }
81
+
82
+ protected createChunk(nodes: object[]): IdChunk {
83
+ return new IdChunk(nodes, this.getId)
84
+ }
85
+
86
+ protected onChunkChanged(chunk: IdChunk): void {
87
+ chunk.atom.reportChanged()
88
+ }
89
+
90
+ protected onChunksChanged(): void {
91
+ this.chunksAtom.reportChanged()
92
+ }
93
+
94
+ protected onChunkDropped(chunk: IdChunk): void {
95
+ // its nodes (if any) moved to a chunk that will add their ids again
96
+ const synced = chunk.syncedIds
97
+ if (synced && synced.size > 0) {
98
+ for (const [id, value] of synced) {
99
+ forEachNode(value, (node) => this.byId.delete(id, node))
100
+ }
101
+ this.changedOutsideSync = true
102
+ }
103
+ }
104
+
105
+ private syncChunk(chunk: IdChunk): boolean {
106
+ const ids = chunk.ids.get()
107
+ const previous = chunk.syncedIds
108
+ if (ids === previous) {
109
+ return false
110
+ }
111
+ chunk.syncedIds = ids
112
+
113
+ let changed = false
114
+ if (previous) {
115
+ for (const [id, value] of previous) {
116
+ if (!sameNodes(ids.get(id), value)) {
117
+ forEachNode(value, (node) => this.byId.delete(id, node))
118
+ changed = true
119
+ }
120
+ }
121
+ }
122
+ for (const [id, value] of ids) {
123
+ if (!sameNodes(previous?.get(id), value)) {
124
+ forEachNode(value, (node) => this.byId.add(id, node))
125
+ changed = true
126
+ }
127
+ }
128
+ return changed
129
+ }
130
+ }
131
+
132
+ function forEachNode(value: object | object[], fn: (node: object) => void) {
133
+ if (Array.isArray(value)) {
134
+ value.forEach(fn)
135
+ } else {
136
+ fn(value)
137
+ }
138
+ }
139
+
140
+ function sameNodes(a: object | object[] | undefined, b: object | object[]) {
141
+ if (a === b) {
142
+ return true
143
+ }
144
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
145
+ return false
146
+ }
147
+ return a.every((node, i) => node === b[i])
148
+ }
149
+
150
+ function createRefIdIndexExtension(getId: GetId) {
151
+ return registerDeepObjectChildrenExtension<RefIdIndex>({
152
+ initData() {
153
+ return new RefIdIndex(getId)
154
+ },
155
+
156
+ addNode(node, index) {
157
+ index.add(node)
158
+ },
159
+
160
+ removeNode(node, index) {
161
+ index.delete(node)
162
+ return true
163
+ },
164
+ })
165
+ }
166
+
167
+ const refIdIndexes = new WeakMap<GetId, DeepObjectChildrenExtensionAccessor<RefIdIndex>>()
168
+
169
+ // Outside reactions and actions MobX does not cache computeds, so every
170
+ // untracked resolution would re-read every id. Keep the index observed until
171
+ // any of its ids change, then release it, so ids read from outside the tree
172
+ // cannot keep it alive.
173
+ function keepAliveUntilChanged(index: RefIdIndex) {
174
+ if (index.keeper) {
175
+ return
176
+ }
177
+ const keeper = new Reaction("refIdIndex keeper", () => {
178
+ keeper.dispose()
179
+ if (index.keeper === keeper) {
180
+ index.keeper = undefined
181
+ }
182
+ })
183
+ index.keeper = keeper
184
+ keeper.track(() => {
185
+ try {
186
+ index.synced.get()
187
+ } catch {
188
+ // the caller's own read rethrows the cached error
189
+ }
190
+ })
191
+ }
192
+
193
+ /**
194
+ * Resolves a node below the root (excluding it) whose `getId` is the given
195
+ * id, reactively, in O(tree size / 128) plus the cost of re-reading the ids of
196
+ * any chunk of 128 nodes that changed, plus O(nodes × depth) when several
197
+ * nodes have that id.
198
+ *
199
+ * @internal
200
+ */
201
+ export function resolveRefIdByIndex(root: object, id: string, getId: GetId): object | undefined {
202
+ const accessor = getOrCreate(refIdIndexes, getId, () => createRefIdIndexExtension(getId))
203
+ const index = accessor.get(root, false)
204
+
205
+ keepAliveUntilChanged(index)
206
+ index.synced.get()
207
+
208
+ return index.byId.resolve(root, id)
209
+ }
@@ -4,9 +4,10 @@ import { HookAction } from "../action/hookActions"
4
4
  import { wrapInAction, wrapModelMethodInActionIfNeeded } from "../action/wrapInAction"
5
5
  import { type AnyModel, BaseModel } from "../model/BaseModel"
6
6
  import { WalkTreeMode, walkTree } from "../parent/walkTree"
7
+ import { forEachWithDelayedThrow } from "../utils/forEachWithDelayedThrow"
7
8
 
8
9
  const onAttachedDisposers = new WeakMap<object, () => void>()
9
- const attachedToRootStore = new WeakSet<object>()
10
+ const attachedToRootStore = new WeakMap<object, object>()
10
11
 
11
12
  /**
12
13
  * @internal
@@ -16,6 +17,7 @@ export const attachToRootStore = action(
16
17
  (rootStore: object, child: object): void => {
17
18
  // we use an array to ensure they will get called even if the actual hook modifies the tree
18
19
  const childrenToCall: AnyModel[] = []
20
+ const attachment = {}
19
21
 
20
22
  walkTree(
21
23
  child,
@@ -25,7 +27,7 @@ export const attachToRootStore = action(
25
27
  if (attachedToRootStore.has(ch)) {
26
28
  return
27
29
  }
28
- attachedToRootStore.add(ch)
30
+ attachedToRootStore.set(ch, attachment)
29
31
 
30
32
  if (ch instanceof BaseModel && (ch as any).onAttachedToRootStore) {
31
33
  wrapModelMethodInActionIfNeeded(
@@ -40,15 +42,23 @@ export const attachToRootStore = action(
40
42
  WalkTreeMode.ParentFirst
41
43
  )
42
44
 
43
- const childrenToCallLen = childrenToCall.length
44
- for (let i = 0; i < childrenToCallLen; i++) {
45
- const ch = childrenToCall[i]
45
+ forEachWithDelayedThrow(childrenToCall, (ch) => {
46
+ // An earlier hook may have detached or reattached this node already.
47
+ if (attachedToRootStore.get(ch) !== attachment) {
48
+ return
49
+ }
46
50
 
47
51
  const disposer = (ch as any).onAttachedToRootStore(rootStore)
48
52
  if (disposer) {
49
- onAttachedDisposers.set(ch, disposer)
53
+ if (attachedToRootStore.get(ch) === attachment) {
54
+ onAttachedDisposers.set(ch, disposer)
55
+ } else {
56
+ // The hook detached or reattached this node before returning its cleanup.
57
+ // Dispose this attachment without overwriting a newer attachment's cleanup.
58
+ wrapDisposer(ch, disposer)()
59
+ }
50
60
  }
51
- }
61
+ })
52
62
  }
53
63
  )
54
64
 
@@ -69,11 +79,7 @@ export const detachFromRootStore = action("detachFromRootStore", (child: object)
69
79
  const disposer = onAttachedDisposers.get(ch)
70
80
  if (disposer) {
71
81
  // wrap disposer in action
72
- const disposerAction = wrapInAction({
73
- nameOrNameFn: HookAction.OnAttachedToRootStoreDisposer,
74
- fn: disposer,
75
- actionType: ActionContextActionType.Sync,
76
- }).bind(ch)
82
+ const disposerAction = wrapDisposer(ch, disposer)
77
83
  onAttachedDisposers.delete(ch)
78
84
 
79
85
  disposersToCall.push(disposerAction)
@@ -82,8 +88,13 @@ export const detachFromRootStore = action("detachFromRootStore", (child: object)
82
88
  WalkTreeMode.ChildrenFirst
83
89
  )
84
90
 
85
- const disposersToCallLen = disposersToCall.length
86
- for (let i = 0; i < disposersToCallLen; i++) {
87
- disposersToCall[i]()
88
- }
91
+ forEachWithDelayedThrow(disposersToCall, (dispose) => dispose())
89
92
  })
93
+
94
+ function wrapDisposer(node: object, disposer: () => void): () => void {
95
+ return wrapInAction({
96
+ nameOrNameFn: HookAction.OnAttachedToRootStoreDisposer,
97
+ fn: disposer,
98
+ actionType: ActionContextActionType.Sync,
99
+ }).bind(node)
100
+ }
@@ -45,9 +45,11 @@ export const registerRootStore: <T extends object>(node: T) => T = action(
45
45
 
46
46
  entry.is = true
47
47
 
48
- attachToRootStore(node, node)
49
-
50
- entry.atom?.reportChanged()
48
+ try {
49
+ attachToRootStore(node, node)
50
+ } finally {
51
+ entry.atom?.reportChanged()
52
+ }
51
53
  return node
52
54
  }
53
55
  )
@@ -65,9 +67,11 @@ export const unregisterRootStore: (node: object) => void = action("unregisterRoo
65
67
  const entry = getOrCreateRootStoreEntry(node)
66
68
  entry.is = false
67
69
 
68
- detachFromRootStore(node)
69
-
70
- entry.atom?.reportChanged()
70
+ try {
71
+ detachFromRootStore(node)
72
+ } finally {
73
+ entry.atom?.reportChanged()
74
+ }
71
75
  })
72
76
 
73
77
  /**