mobx-keystone 1.22.0 → 1.24.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 (77) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1 -1
  4. package/dist/mobx-keystone.esm.js +7198 -6242
  5. package/dist/mobx-keystone.esm.mjs +7198 -6242
  6. package/dist/mobx-keystone.umd.js +7217 -6243
  7. package/dist/types/action/runUnprotected.d.ts +8 -4
  8. package/dist/types/model/Model.d.ts +5 -0
  9. package/dist/types/modelShared/modelDecorator.d.ts +1 -1
  10. package/dist/types/modelShared/prop.d.ts +8 -4
  11. package/dist/types/snapshot/modelInitialData.d.ts +1 -0
  12. package/dist/types/tweaker/treeNodeMetadata.d.ts +1 -0
  13. package/dist/types/types/createCachedTypeCheck.d.ts +1 -0
  14. package/dist/types/utils/index.d.ts +24 -0
  15. package/package.json +15 -11
  16. package/src/action/middleware.ts +5 -18
  17. package/src/action/mutationBatch.ts +72 -0
  18. package/src/action/runUnprotected.ts +21 -8
  19. package/src/action/wrapInAction.ts +45 -15
  20. package/src/actionMiddlewares/undoMiddleware.ts +19 -13
  21. package/src/dataModel/BaseDataModel.ts +4 -8
  22. package/src/deepChange/onDeepChange.ts +246 -16
  23. package/src/model/BaseModel.ts +4 -12
  24. package/src/model/Model.ts +5 -0
  25. package/src/model/newModel.ts +68 -36
  26. package/src/modelShared/modelDecorator.ts +64 -53
  27. package/src/modelShared/prop.ts +105 -63
  28. package/src/modelShared/sharedInternalModel.ts +91 -60
  29. package/src/parent/core.ts +16 -10
  30. package/src/parent/coreObjectChildren.ts +219 -21
  31. package/src/parent/path.ts +6 -5
  32. package/src/parent/setParent.ts +5 -5
  33. package/src/patch/emitPatch.ts +102 -20
  34. package/src/ref/Ref.ts +4 -5
  35. package/src/ref/core.ts +2 -2
  36. package/src/snapshot/fromArraySnapshot.ts +11 -2
  37. package/src/snapshot/fromModelSnapshot.ts +10 -3
  38. package/src/snapshot/fromPlainObjectSnapshot.ts +18 -7
  39. package/src/snapshot/fromSnapshot.ts +31 -9
  40. package/src/snapshot/getSnapshot.ts +8 -3
  41. package/src/snapshot/internal.ts +243 -82
  42. package/src/snapshot/modelInitialData.ts +41 -0
  43. package/src/snapshot/reconcileArraySnapshot.ts +9 -3
  44. package/src/snapshot/reconcileModelSnapshot.ts +97 -17
  45. package/src/snapshot/reconcilePlainObjectSnapshot.ts +9 -3
  46. package/src/treeUtils/draft.ts +7 -8
  47. package/src/tweaker/core.ts +30 -5
  48. package/src/tweaker/treeNodeMetadata.ts +38 -0
  49. package/src/tweaker/tweak.ts +15 -4
  50. package/src/tweaker/tweakArray.ts +159 -121
  51. package/src/tweaker/tweakFrozen.ts +2 -2
  52. package/src/tweaker/tweakModel.ts +2 -2
  53. package/src/tweaker/tweakPlainObject.ts +117 -124
  54. package/src/tweaker/typeChecking.ts +10 -1
  55. package/src/types/TypeChecker.ts +261 -15
  56. package/src/types/arrayBased/typesArray.ts +38 -40
  57. package/src/types/arrayBased/typesTuple.ts +51 -34
  58. package/src/types/createCachedTypeCheck.ts +181 -0
  59. package/src/types/objectBased/typesArraySet.ts +42 -32
  60. package/src/types/objectBased/typesDataModelData.ts +18 -11
  61. package/src/types/objectBased/typesModel.ts +1 -1
  62. package/src/types/objectBased/typesObject.ts +57 -28
  63. package/src/types/objectBased/typesObjectMap.ts +51 -40
  64. package/src/types/objectBased/typesRecord.ts +59 -39
  65. package/src/types/objectBased/typesRef.ts +1 -1
  66. package/src/types/primitiveBased/typesPrimitive.ts +9 -9
  67. package/src/types/tProp.ts +80 -44
  68. package/src/types/utility/typesOr.ts +35 -24
  69. package/src/types/utility/typesRefinement.ts +21 -8
  70. package/src/types/utility/typesSkipCheck.ts +21 -8
  71. package/src/types/utility/typesTag.ts +21 -9
  72. package/src/types/utility/typesUnchecked.ts +2 -3
  73. package/src/utils/ModelPool.ts +34 -19
  74. package/src/utils/errorDiagnostics.ts +60 -18
  75. package/src/utils/index.ts +142 -9
  76. package/src/types/createPerEntryCachedCheck.ts +0 -166
  77. /package/dist/types/{types/createPerEntryCachedCheck.d.ts → action/mutationBatch.d.ts} +0 -0
@@ -1,13 +1,9 @@
1
1
  import { createAtom, type IAtom } from "mobx"
2
2
  import { isModel } from "../model/utils"
3
+ import { treeNodeMetadata } from "../tweaker/treeNodeMetadata"
3
4
  import { getOrCreate } from "../utils/mapUtils"
4
5
  import type { ParentPath } from "./path"
5
6
 
6
- /**
7
- * @internal
8
- */
9
- export const objectParents = new WeakMap<object, ParentPath<object> | undefined>()
10
-
11
7
  const objectParentsAtoms = new WeakMap<object, IAtom>()
12
8
 
13
9
  /**
@@ -44,16 +40,26 @@ export function reportParentPathChanged(node: object) {
44
40
  objectParentsAtoms.get(node)?.reportChanged()
45
41
  }
46
42
 
47
- /**
48
- * @internal
49
- */
50
- export const dataObjectParent = new WeakMap<object, object>()
43
+ /** @internal */
44
+ export function getDataObjectParent(node: object): object | undefined {
45
+ return treeNodeMetadata.get(node)?.dataObjectParent
46
+ }
47
+
48
+ /** @internal */
49
+ export function hasDataObjectParent(node: object): boolean {
50
+ return treeNodeMetadata.get(node)?.dataObjectParent !== undefined
51
+ }
52
+
53
+ /** @internal */
54
+ export function setDataObjectParent(node: object, parent: object): void {
55
+ treeNodeMetadata.get(node)!.dataObjectParent = parent
56
+ }
51
57
 
52
58
  /**
53
59
  * @internal
54
60
  */
55
61
  export function dataToModelNode<T extends object>(node: T): T {
56
- const modelNode = dataObjectParent.get(node)
62
+ const modelNode = getDataObjectParent(node)
57
63
  return (modelNode as T | undefined) ?? node
58
64
  }
59
65
 
@@ -1,13 +1,16 @@
1
1
  import { action, createAtom, type IAtom } from "mobx"
2
+ import { addMutationBatchFinisher, isMutationBatchActive } from "../action/mutationBatch"
3
+ import { getOrCreateTreeNodeMetadata } from "../tweaker/treeNodeMetadata"
2
4
  import { fastGetParent } from "./path"
3
5
 
4
6
  interface DeepObjectChildren {
5
7
  deep: Set<object>
6
8
 
7
- extensionsData: Map<object, unknown>
9
+ extensionsData: Map<object, unknown> | undefined
8
10
  }
9
11
 
10
- interface ObjectChildrenData extends DeepObjectChildren {
12
+ /** @internal */
13
+ export interface ObjectChildrenData extends DeepObjectChildren {
11
14
  shallow: Set<object>
12
15
  shallowAtom: IAtom | undefined // will be created when first observed
13
16
 
@@ -15,10 +18,55 @@ interface ObjectChildrenData extends DeepObjectChildren {
15
18
  deepAtom: IAtom | undefined // will be created when first observed
16
19
  }
17
20
 
18
- const objectChildren = new WeakMap<object, ObjectChildrenData>()
21
+ interface DeepChildrenInvalidationEntry {
22
+ readonly obj: ObjectChildrenData
23
+ rebuilt: boolean
24
+ }
25
+
26
+ interface DirectChildrenMutationEntry {
27
+ added: Set<object> | undefined
28
+ removed: Set<object> | undefined
29
+ invalidatedNodes: Set<object> | undefined
30
+ }
31
+
32
+ interface DeepChildrenMutationTransaction {
33
+ directMutations: Map<object, DirectChildrenMutationEntry> | undefined
34
+ invalidatedNodes: Map<object, DeepChildrenInvalidationEntry> | undefined
35
+ }
36
+
37
+ let currentDeepChildrenMutationTransaction: DeepChildrenMutationTransaction | undefined
38
+
39
+ function getCurrentDeepChildrenMutationTransaction(): DeepChildrenMutationTransaction | undefined {
40
+ return currentDeepChildrenMutationTransaction
41
+ }
42
+
43
+ function getOrCreateDeepChildrenMutationTransaction(): DeepChildrenMutationTransaction | undefined {
44
+ if (!isMutationBatchActive()) {
45
+ return undefined
46
+ }
47
+
48
+ const currentTransaction = currentDeepChildrenMutationTransaction
49
+ if (currentTransaction) {
50
+ return currentTransaction
51
+ }
52
+
53
+ const transaction: DeepChildrenMutationTransaction = {
54
+ directMutations: undefined,
55
+ invalidatedNodes: undefined,
56
+ }
57
+ currentDeepChildrenMutationTransaction = transaction
58
+ addMutationBatchFinisher(() => {
59
+ if (currentDeepChildrenMutationTransaction === transaction) {
60
+ currentDeepChildrenMutationTransaction = undefined
61
+ finishDeepChildrenMutationTransaction(transaction)
62
+ }
63
+ })
64
+ return transaction
65
+ }
19
66
 
20
67
  function getObjectChildrenObject(node: object) {
21
- let obj = objectChildren.get(node)
68
+ const metadata = getOrCreateTreeNodeMetadata(node)
69
+ let obj = metadata.objectChildren
22
70
 
23
71
  if (!obj) {
24
72
  obj = {
@@ -31,7 +79,7 @@ function getObjectChildrenObject(node: object) {
31
79
 
32
80
  extensionsData: initExtensionsData(),
33
81
  }
34
- objectChildren.set(node, obj)
82
+ metadata.objectChildren = obj
35
83
  }
36
84
 
37
85
  return obj
@@ -69,17 +117,36 @@ export function getDeepObjectChildren(node: object): DeepObjectChildren {
69
117
 
70
118
  function addNodeToDeepLists(node: any, data: DeepObjectChildren) {
71
119
  data.deep.add(node)
72
- data.extensionsData.forEach((extensionData, dataSymbol) => {
120
+ data.extensionsData?.forEach((extensionData, dataSymbol) => {
73
121
  extensions.get(dataSymbol)!.addNode(node, extensionData)
74
122
  })
75
123
  }
76
124
 
77
125
  const updateDeepObjectChildren = action((node: object): DeepObjectChildren => {
126
+ return updateDeepObjectChildrenRecursive(node)
127
+ })
128
+
129
+ function updateDeepObjectChildrenRecursive(node: object): DeepObjectChildren {
78
130
  const obj = getObjectChildrenObject(node)
79
131
  if (!obj.deepDirty) {
80
132
  return obj
81
133
  }
82
134
 
135
+ const transaction = getCurrentDeepChildrenMutationTransaction()
136
+ if (transaction) {
137
+ const invalidatedNodes = (transaction.invalidatedNodes ??= new Map())
138
+ const invalidationEntry = invalidatedNodes.get(node)
139
+ if (invalidationEntry) {
140
+ invalidationEntry.rebuilt = true
141
+ } else {
142
+ // a node without an entry was dirty when first invalidated in this batch
143
+ // (or was dirtied before it); record the rebuild so a later net-zero
144
+ // completion cannot restore this mid-batch state as if the node had been
145
+ // clean since the batch started
146
+ invalidatedNodes.set(node, { obj, rebuilt: true })
147
+ }
148
+ }
149
+
83
150
  obj.deep = new Set()
84
151
  obj.extensionsData = initExtensionsData(obj.extensionsData)
85
152
 
@@ -88,7 +155,7 @@ const updateDeepObjectChildren = action((node: object): DeepObjectChildren => {
88
155
  while (!childrenIteratorResult.done) {
89
156
  addNodeToDeepLists(childrenIteratorResult.value, obj)
90
157
 
91
- const childDeepChildren = updateDeepObjectChildren(childrenIteratorResult.value).deep
158
+ const childDeepChildren = updateDeepObjectChildrenRecursive(childrenIteratorResult.value).deep
92
159
  const childDeepChildrenIterator = childDeepChildren.values()
93
160
  let childDeepChildrenIteratorResult = childDeepChildrenIterator.next()
94
161
  while (!childDeepChildrenIteratorResult.done) {
@@ -103,35 +170,115 @@ const updateDeepObjectChildren = action((node: object): DeepObjectChildren => {
103
170
  obj.deepAtom?.reportChanged()
104
171
 
105
172
  return obj
106
- })
173
+ }
107
174
 
108
175
  /**
176
+ * Must run inside the action-wrapped `setParent`, its only source caller.
177
+ *
109
178
  * @internal
110
179
  */
111
- export const addObjectChild = action((node: object, child: object) => {
180
+ export function addObjectChild(node: object, child: object): void {
112
181
  const obj = getObjectChildrenObject(node)
113
- obj.shallow.add(child)
114
- obj.shallowAtom?.reportChanged()
182
+ const shallow = obj.shallow
183
+ const previousShallowSize = shallow.size
184
+ shallow.add(child)
185
+ if (shallow.size === previousShallowSize) {
186
+ return
187
+ }
115
188
 
116
- invalidateDeepChildren(node, obj)
117
- })
189
+ onShallowChildrenChanged(node, obj, child, true)
190
+ }
118
191
 
119
192
  /**
193
+ * Must run inside the action-wrapped `setParent`, its only source caller.
194
+ *
120
195
  * @internal
121
196
  */
122
- export const removeObjectChild = action((node: object, child: object) => {
197
+ export function removeObjectChild(node: object, child: object): void {
123
198
  const obj = getObjectChildrenObject(node)
124
- obj.shallow.delete(child)
199
+ if (!obj.shallow.delete(child)) {
200
+ return
201
+ }
202
+
203
+ onShallowChildrenChanged(node, obj, child, false)
204
+ }
205
+
206
+ function onShallowChildrenChanged(
207
+ node: object,
208
+ obj: ObjectChildrenData,
209
+ child: object,
210
+ added: boolean
211
+ ) {
125
212
  obj.shallowAtom?.reportChanged()
126
213
 
127
- invalidateDeepChildren(node, obj)
128
- })
214
+ const transaction =
215
+ currentDeepChildrenMutationTransaction ??
216
+ (obj.deepDirty ? undefined : getOrCreateDeepChildrenMutationTransaction())
217
+ invalidateDeepChildren(
218
+ node,
219
+ obj,
220
+ transaction,
221
+ recordDirectChildrenMutation(transaction, node, child, added)
222
+ )
223
+ }
224
+
225
+ function recordDirectChildrenMutation(
226
+ transaction: DeepChildrenMutationTransaction | undefined,
227
+ node: object,
228
+ child: object,
229
+ added: boolean
230
+ ): DirectChildrenMutationEntry | undefined {
231
+ if (!transaction) {
232
+ return undefined
233
+ }
234
+
235
+ const directMutations = (transaction.directMutations ??= new Map())
236
+ let mutation = directMutations.get(node)
237
+ if (!mutation) {
238
+ mutation = {
239
+ added: undefined,
240
+ removed: undefined,
241
+ invalidatedNodes: undefined,
242
+ }
243
+ directMutations.set(node, mutation)
244
+ }
245
+
246
+ if (added) {
247
+ if (!mutation.removed?.delete(child)) {
248
+ ;(mutation.added ??= new Set()).add(child)
249
+ }
250
+ } else if (!mutation.added?.delete(child)) {
251
+ ;(mutation.removed ??= new Set()).add(child)
252
+ }
253
+
254
+ return mutation
255
+ }
129
256
 
130
- function invalidateDeepChildren(node: object, obj: ObjectChildrenData) {
257
+ function invalidateDeepChildren(
258
+ node: object,
259
+ obj: ObjectChildrenData,
260
+ transaction: DeepChildrenMutationTransaction | undefined,
261
+ mutation: DirectChildrenMutationEntry | undefined
262
+ ) {
131
263
  let currentNode: object | undefined = node
132
264
  let currentObj = obj
133
265
 
134
266
  while (currentNode) {
267
+ if (transaction) {
268
+ let entry = transaction.invalidatedNodes?.get(currentNode)
269
+ if (!entry && !currentObj.deepDirty) {
270
+ const invalidatedNodes = (transaction.invalidatedNodes ??= new Map())
271
+ entry = {
272
+ obj: currentObj,
273
+ rebuilt: false,
274
+ }
275
+ invalidatedNodes.set(currentNode, entry)
276
+ }
277
+ if (mutation && entry) {
278
+ ;(mutation.invalidatedNodes ??= new Set()).add(currentNode)
279
+ }
280
+ }
281
+
135
282
  currentObj.deepDirty = true
136
283
  currentObj.deepAtom?.reportChanged()
137
284
 
@@ -142,6 +289,51 @@ function invalidateDeepChildren(node: object, obj: ObjectChildrenData) {
142
289
  }
143
290
  }
144
291
 
292
+ function finishDeepChildrenMutationTransaction(transaction: DeepChildrenMutationTransaction) {
293
+ const invalidatedNodes = transaction.invalidatedNodes
294
+ if (!invalidatedNodes) {
295
+ return
296
+ }
297
+
298
+ // when every mutated parent has net membership changes every invalidated
299
+ // node must stay dirty, so there is nothing to restore
300
+ let hasNetZeroMutation = false
301
+ transaction.directMutations?.forEach((mutation) => {
302
+ if ((mutation.added?.size ?? 0) === 0 && (mutation.removed?.size ?? 0) === 0) {
303
+ hasNetZeroMutation = true
304
+ }
305
+ })
306
+ if (!hasNetZeroMutation) {
307
+ return
308
+ }
309
+
310
+ let genuinelyChangedNodes: Set<object> | undefined
311
+ transaction.directMutations?.forEach((mutation, node) => {
312
+ if ((mutation.added?.size ?? 0) > 0 || (mutation.removed?.size ?? 0) > 0) {
313
+ const changedNodes = (genuinelyChangedNodes ??= new Set())
314
+ mutation.invalidatedNodes?.forEach((invalidatedNode) => {
315
+ changedNodes.add(invalidatedNode)
316
+ })
317
+
318
+ // a genuine membership change also dirties the node's ancestor chain as
319
+ // it stands now, which may differ from the chains recorded at mutation
320
+ // time (e.g. a subtree mutated while detached and then re-attached with
321
+ // a net-zero remove/add pair at its parent)
322
+ let currentNode: object | undefined = node
323
+ while (currentNode) {
324
+ changedNodes.add(currentNode)
325
+ currentNode = fastGetParent(currentNode, false)
326
+ }
327
+ }
328
+ })
329
+
330
+ invalidatedNodes.forEach((entry, node) => {
331
+ if (!genuinelyChangedNodes?.has(node) && !entry.rebuilt) {
332
+ entry.obj.deepDirty = false
333
+ }
334
+ })
335
+ }
336
+
145
337
  const extensions = new Map<object, DeepObjectChildrenExtension<any>>()
146
338
 
147
339
  interface DeepObjectChildrenExtension<D> {
@@ -157,10 +349,12 @@ export function registerDeepObjectChildrenExtension<D>(extension: DeepObjectChil
157
349
  extensions.set(dataSymbol, extension)
158
350
 
159
351
  return (data: DeepObjectChildren): D => {
160
- let extensionData = data.extensionsData.get(dataSymbol) as D | undefined
161
- if (!data.extensionsData.has(dataSymbol)) {
352
+ let extensionsData = data.extensionsData
353
+ let extensionData = extensionsData?.get(dataSymbol) as D | undefined
354
+ if (!extensionsData?.has(dataSymbol)) {
355
+ extensionsData = data.extensionsData ??= new Map()
162
356
  extensionData = extension.initData()
163
- data.extensionsData.set(dataSymbol, extensionData)
357
+ extensionsData.set(dataSymbol, extensionData)
164
358
  data.deep.forEach((node) => {
165
359
  extension.addNode(node, extensionData!)
166
360
  })
@@ -170,6 +364,10 @@ export function registerDeepObjectChildrenExtension<D>(extension: DeepObjectChil
170
364
  }
171
365
 
172
366
  function initExtensionsData(previousData?: ReadonlyMap<object, unknown>) {
367
+ if (previousData === undefined || previousData.size === 0) {
368
+ return undefined
369
+ }
370
+
173
371
  const extensionsData = new Map<object, unknown>()
174
372
 
175
373
  previousData?.forEach((_, dataSymbol) => {
@@ -1,12 +1,13 @@
1
1
  import { modelIdKey } from "../model/metadata"
2
2
  import { isModel } from "../model/utils"
3
3
  import { assertTweakedObject } from "../tweaker/core"
4
+ import { treeNodeMetadata } from "../tweaker/treeNodeMetadata"
4
5
  import { hasOwnProp, isArray, isObject } from "../utils"
5
6
  import {
6
- dataObjectParent,
7
7
  dataToModelNode,
8
+ getDataObjectParent,
9
+ hasDataObjectParent,
8
10
  modelToDataNode,
9
- objectParents,
10
11
  reportParentPathObserved,
11
12
  } from "./core"
12
13
  import type { Path, PathElement, WritablePath } from "./pathTypes"
@@ -73,7 +74,7 @@ export function fastGetParentPath<T extends object = any>(
73
74
  if (useAtom) {
74
75
  reportParentPathObserved(value)
75
76
  }
76
- return objectParents.get(value) as ParentPath<T> | undefined
77
+ return treeNodeMetadata.get(value)?.parentPath as ParentPath<T> | undefined
77
78
  }
78
79
 
79
80
  /**
@@ -83,7 +84,7 @@ export function fastGetParentPathIncludingDataObjects<T extends object = any>(
83
84
  value: object,
84
85
  useAtom: boolean
85
86
  ): ParentPath<T> | undefined {
86
- const parentModel = dataObjectParent.get(value)
87
+ const parentModel = getDataObjectParent(value)
87
88
  if (parentModel) {
88
89
  return { parent: parentModel as T, path: "$" }
89
90
  }
@@ -144,7 +145,7 @@ export function isModelDataObject(value: object): boolean {
144
145
  * @internal
145
146
  */
146
147
  export function fastIsModelDataObject(value: object): boolean {
147
- return dataObjectParent.has(value)
148
+ return hasDataObjectParent(value)
148
149
  }
149
150
 
150
151
  /**
@@ -7,14 +7,14 @@ import { attachToRootStore, detachFromRootStore } from "../rootStore/attachDetac
7
7
  import { fastIsRootStoreNoAtom } from "../rootStore/rootStore"
8
8
  import { clone } from "../snapshot/clone"
9
9
  import { isTweakedObject } from "../tweaker/core"
10
+ import { treeNodeMetadata } from "../tweaker/treeNodeMetadata"
10
11
  import { tryUntweak } from "../tweaker/tweak"
11
12
  import { failure, inDevMode, isPrimitive } from "../utils"
12
13
  import {
13
- dataObjectParent,
14
14
  dataToModelNode,
15
- objectParents,
16
15
  parentPathEquals,
17
16
  reportParentPathChanged,
17
+ setDataObjectParent,
18
18
  } from "./core"
19
19
  import { addObjectChild, removeObjectChild } from "./coreObjectChildren"
20
20
  import { fastGetParentPath, fastGetRoot, type ParentPath } from "./path"
@@ -62,7 +62,7 @@ export const setParent = action(
62
62
  }
63
63
 
64
64
  if (isDataObject) {
65
- dataObjectParent.set(value, parentPath!.parent)
65
+ setDataObjectParent(value, parentPath!.parent)
66
66
  // data object will proxy to use the actual parent model for child/parent stuff
67
67
  return value
68
68
  }
@@ -94,7 +94,7 @@ export const setParent = action(
94
94
  if (oldParentPath && parentPath) {
95
95
  if (oldParentPath.parent === parentPath.parent && indexChangeAllowed) {
96
96
  // just changing the index
97
- objectParents.set(value, parentPath)
97
+ treeNodeMetadata.get(value)!.parentPath = parentPath
98
98
  reportParentPathChanged(value)
99
99
  return value
100
100
  } else {
@@ -119,7 +119,7 @@ export const setParent = action(
119
119
  }
120
120
 
121
121
  // attach to new
122
- objectParents.set(value, parentPath)
122
+ treeNodeMetadata.get(value)!.parentPath = parentPath
123
123
  if (parentPath?.parent) {
124
124
  addObjectChild(parentPath.parent, value)
125
125
  }
@@ -1,5 +1,6 @@
1
1
  import { action, isAction } from "mobx"
2
- import { fastGetParentPath } from "../parent/path"
2
+ import { dataToModelNode } from "../parent/core"
3
+ import { fastGetParentPath, type ParentPath } from "../parent/path"
3
4
  import type { PathElement } from "../parent/pathTypes"
4
5
  import { freezeInternalSnapshot, getInternalSnapshot } from "../snapshot/internal"
5
6
  import { assertTweakedObject } from "../tweaker/core"
@@ -14,19 +15,27 @@ const emptyPatchArray: Patch[] = []
14
15
  export class InternalPatchRecorder {
15
16
  patches: Patch[] = emptyPatchArray
16
17
  invPatches: Patch[] = emptyPatchArray
18
+ hasChanges = false
17
19
 
18
20
  reset() {
19
21
  this.patches = emptyPatchArray
20
22
  this.invPatches = emptyPatchArray
23
+ this.hasChanges = false
21
24
  }
22
25
 
23
- record(patches: Patch[], invPatches: Patch[]) {
24
- this.patches = patches
25
- this.invPatches = invPatches
26
+ record(patches: Patch[] | undefined, invPatches: Patch[] | undefined) {
27
+ this.patches = patches ?? emptyPatchArray
28
+ this.invPatches = invPatches ?? emptyPatchArray
29
+ // An effective change requires at least one forward or inverse patch. A no-op
30
+ // mutation (e.g. an equal-count splice where every value is identical) records
31
+ // empty arrays and must not trigger ancestor type checking.
32
+ this.hasChanges = this.patches.length > 0 || this.invPatches.length > 0
26
33
  }
27
34
 
28
35
  emit(obj: object) {
29
- emitPatches(obj, this.patches, this.invPatches)
36
+ if (this.patches.length > 0) {
37
+ emitPatches(obj, this.patches, this.invPatches)
38
+ }
30
39
  this.reset()
31
40
  }
32
41
  }
@@ -35,9 +44,11 @@ export class InternalPatchRecorder {
35
44
  * @internal
36
45
  */
37
46
  export function emitPatches(obj: object, patches: Patch[], invPatches: Patch[]): void {
38
- if (patches.length > 0 || invPatches.length > 0) {
47
+ if (patches.length > 0) {
39
48
  emitGlobalPatch(obj, patches, invPatches)
40
- emitPatch(obj, patches, invPatches)
49
+ if (patchListenerCount > 0) {
50
+ emitPatch(obj, patches, invPatches)
51
+ }
41
52
  }
42
53
  }
43
54
 
@@ -62,6 +73,37 @@ export type OnPatchesDisposer = () => void
62
73
 
63
74
  const patchListeners = new WeakMap<object, OnPatchesListener[]>()
64
75
  const globalPatchListeners: OnGlobalPatchesListener[] = []
76
+ let patchListenerCount = 0
77
+
78
+ /**
79
+ * @internal
80
+ */
81
+ export function hasGlobalPatchListeners(): boolean {
82
+ return globalPatchListeners.length > 0
83
+ }
84
+
85
+ /**
86
+ * @internal
87
+ */
88
+ export function hasPatchListenersFor(target: object): boolean {
89
+ if (hasGlobalPatchListeners()) {
90
+ return true
91
+ }
92
+ if (patchListenerCount === 0) {
93
+ return false
94
+ }
95
+
96
+ let current: object | undefined = dataToModelNode(target)
97
+ while (current) {
98
+ if ((patchListeners.get(current)?.length ?? 0) > 0) {
99
+ return true
100
+ }
101
+
102
+ const parentPath: ParentPath<object> | undefined = fastGetParentPath(current, false)
103
+ current = parentPath?.parent
104
+ }
105
+ return false
106
+ }
65
107
 
66
108
  /**
67
109
  * Adds a listener that will be called every time a patch is generated for the tree of the given target object.
@@ -85,8 +127,11 @@ export function onPatches(subtreeRoot: object, listener: OnPatchesListener): OnP
85
127
  }
86
128
 
87
129
  listenersForObject.push(listener)
130
+ patchListenerCount++
88
131
  return () => {
89
- deleteFromArray(listenersForObject, listener)
132
+ if (deleteFromArray(listenersForObject, listener)) {
133
+ patchListenerCount--
134
+ }
90
135
  }
91
136
  }
92
137
 
@@ -117,11 +162,16 @@ function emitGlobalPatch(obj: object, patches: Patch[], inversePatches: Patch[])
117
162
  }
118
163
  }
119
164
 
165
+ // `reversedPrefix` holds the path segments from the changed node up to (but not
166
+ // including) the current target, in child-to-root order. The prefix to prepend to
167
+ // a patch is that slice reversed (root-to-child); `prefixCount` is how many of the
168
+ // leading entries are relevant for the current target.
120
169
  function emitPatchForTarget(
121
170
  obj: object,
122
171
  patches: Patch[],
123
172
  inversePatches: Patch[],
124
- pathPrefix: PathElement[]
173
+ reversedPrefix: readonly PathElement[],
174
+ prefixCount: number
125
175
  ): void {
126
176
  const listenersForObject = patchListeners.get(obj)
127
177
 
@@ -129,11 +179,10 @@ function emitPatchForTarget(
129
179
  return
130
180
  }
131
181
 
132
- const fixPath = (patchesArray: Patch[]) =>
133
- pathPrefix.length > 0 ? patchesArray.map((p) => addPathToPatch(p, pathPrefix)) : patchesArray
134
-
135
- const patchesWithPathPrefix = fixPath(patches)
136
- const invPatchesWithPathPrefix = fixPath(inversePatches)
182
+ const patchesWithPathPrefix =
183
+ prefixCount > 0 ? prefixPatches(patches, reversedPrefix, prefixCount) : patches
184
+ const invPatchesWithPathPrefix =
185
+ prefixCount > 0 ? prefixPatches(inversePatches, reversedPrefix, prefixCount) : inversePatches
137
186
 
138
187
  for (let i = 0; i < listenersForObject.length; i++) {
139
188
  const listener = listenersForObject[i]
@@ -142,24 +191,57 @@ function emitPatchForTarget(
142
191
  }
143
192
 
144
193
  function emitPatch(obj: object, patches: Patch[], inversePatches: Patch[]): void {
145
- const pathPrefix: PathElement[] = []
194
+ // Segments are pushed in child-to-root order (O(1) each) rather than unshifted
195
+ // at the front (O(depth) each), keeping the whole walk O(depth) instead of
196
+ // O(depth^2) on deep trees.
197
+ const reversedPrefix: PathElement[] = []
146
198
 
147
- emitPatchForTarget(obj, patches, inversePatches, pathPrefix)
199
+ emitPatchForTarget(obj, patches, inversePatches, reversedPrefix, 0)
148
200
 
149
201
  // and also emit subtree listeners all the way to the root
150
202
  let parentPath = fastGetParentPath(obj, false)
151
203
  while (parentPath) {
152
- pathPrefix.unshift(parentPath.path)
153
- emitPatchForTarget(parentPath.parent, patches, inversePatches, pathPrefix)
204
+ reversedPrefix.push(parentPath.path)
205
+ emitPatchForTarget(
206
+ parentPath.parent,
207
+ patches,
208
+ inversePatches,
209
+ reversedPrefix,
210
+ reversedPrefix.length
211
+ )
154
212
 
155
213
  parentPath = fastGetParentPath(parentPath.parent, false)
156
214
  }
157
215
  }
158
216
 
159
- function addPathToPatch(patch: Patch, pathPrefix: readonly PathElement[]): Patch {
217
+ function prefixPatches(
218
+ patchesArray: Patch[],
219
+ reversedPrefix: readonly PathElement[],
220
+ prefixCount: number
221
+ ): Patch[] {
222
+ const result = new Array<Patch>(patchesArray.length)
223
+ for (let i = 0; i < patchesArray.length; i++) {
224
+ result[i] = addPathToPatch(patchesArray[i], reversedPrefix, prefixCount)
225
+ }
226
+ return result
227
+ }
228
+
229
+ function addPathToPatch(
230
+ patch: Patch,
231
+ reversedPrefix: readonly PathElement[],
232
+ prefixCount: number
233
+ ): Patch {
234
+ const patchPath = patch.path
235
+ const newPath = new Array<PathElement>(prefixCount + patchPath.length)
236
+ for (let i = 0; i < prefixCount; i++) {
237
+ newPath[i] = reversedPrefix[prefixCount - 1 - i]
238
+ }
239
+ for (let j = 0; j < patchPath.length; j++) {
240
+ newPath[prefixCount + j] = patchPath[j]
241
+ }
160
242
  return {
161
243
  ...patch,
162
- path: [...pathPrefix, ...patch.path],
244
+ path: newPath,
163
245
  }
164
246
  }
165
247