mobx-keystone 2.0.0 → 2.2.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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/mobx-keystone.esm.js +2561 -1214
  4. package/dist/mobx-keystone.esm.mjs +2561 -1214
  5. package/dist/mobx-keystone.umd.js +2566 -1220
  6. package/dist/types/actionMiddlewares/transactionMiddleware.d.ts +1 -1
  7. package/dist/types/actionMiddlewares/undoMiddleware.d.ts +11 -12
  8. package/dist/types/modelShared/modelDecorator.d.ts +5 -2
  9. package/dist/types/modelShared/prop.d.ts +42 -29
  10. package/dist/types/parent/arrayPaths.d.ts +1 -0
  11. package/dist/types/parent/treeOrder.d.ts +1 -0
  12. package/dist/types/patch/applyPatches.d.ts +2 -1
  13. package/dist/types/ref/Ref.d.ts +1 -4
  14. package/dist/types/ref/modelRefIdIndex.d.ts +1 -0
  15. package/dist/types/ref/nodesById.d.ts +1 -0
  16. package/dist/types/ref/refIdIndex.d.ts +1 -0
  17. package/dist/types/snapshot/SnapshotOf.d.ts +4 -4
  18. package/dist/types/snapshot/applySnapshot.d.ts +2 -1
  19. package/dist/types/snapshot/fromSnapshot.d.ts +2 -5
  20. package/dist/types/snapshot/getSnapshot.d.ts +1 -1
  21. package/dist/types/tweaker/changeRollback.d.ts +1 -0
  22. package/dist/types/tweaker/tweakedChangeListeners.d.ts +1 -0
  23. package/dist/types/types/index.d.ts +1 -0
  24. package/dist/types/types/schemas.d.ts +41 -37
  25. package/dist/types/types/tProp.d.ts +12 -6
  26. package/dist/types/types/types.d.ts +6 -6
  27. package/dist/types/types/utility/typeToStoredData.d.ts +14 -10
  28. package/dist/types/types/utility/typesCodecCore.d.ts +3 -0
  29. package/dist/types/utils/ModelPool.d.ts +7 -2
  30. package/dist/types/utils/chunks.d.ts +1 -0
  31. package/dist/types/utils/types.d.ts +6 -0
  32. package/dist/types/wrappers/ArraySet.d.ts +1 -0
  33. package/package.json +5 -3
  34. package/src/action/modelFlow.ts +35 -66
  35. package/src/actionMiddlewares/actionTrackingMiddleware.ts +41 -71
  36. package/src/actionMiddlewares/transactionMiddleware.ts +41 -42
  37. package/src/actionMiddlewares/undoMiddleware.ts +63 -35
  38. package/src/context/context.ts +15 -36
  39. package/src/dataModel/utils.ts +5 -28
  40. package/src/deepChange/onDeepChange.ts +44 -126
  41. package/src/model/BaseModel.ts +7 -5
  42. package/src/model/utils.ts +5 -10
  43. package/src/modelShared/modelDecorator.ts +16 -30
  44. package/src/modelShared/prop.ts +115 -111
  45. package/src/modelShared/sharedInternalModel.ts +2 -2
  46. package/src/parent/arrayPaths.ts +239 -0
  47. package/src/parent/coreObjectChildren.ts +300 -48
  48. package/src/parent/onChildAttachedTo.ts +130 -44
  49. package/src/parent/path.ts +10 -12
  50. package/src/parent/setParent.ts +27 -15
  51. package/src/parent/treeOrder.ts +53 -0
  52. package/src/parent/walkTree.ts +0 -73
  53. package/src/patch/applyPatches.ts +31 -26
  54. package/src/patch/patchRecorder.ts +13 -28
  55. package/src/ref/core.ts +69 -46
  56. package/src/ref/modelRefIdIndex.ts +131 -0
  57. package/src/ref/nodesById.ts +60 -0
  58. package/src/ref/refIdIndex.ts +209 -0
  59. package/src/rootStore/rootStore.ts +1 -3
  60. package/src/snapshot/SnapshotOf.ts +24 -48
  61. package/src/snapshot/applySnapshot.ts +27 -19
  62. package/src/snapshot/fromPlainObjectSnapshot.ts +3 -8
  63. package/src/snapshot/fromSnapshot.ts +12 -9
  64. package/src/snapshot/getSnapshot.ts +1 -1
  65. package/src/snapshot/internal.ts +67 -12
  66. package/src/snapshot/reconcileModelSnapshot.ts +5 -3
  67. package/src/snapshot/reconcilePlainObjectSnapshot.ts +3 -1
  68. package/src/treeUtils/draft.ts +6 -1
  69. package/src/treeUtils/sandbox.ts +10 -7
  70. package/src/tweaker/changeRollback.ts +218 -0
  71. package/src/tweaker/core.ts +33 -20
  72. package/src/tweaker/treeNodeMetadata.ts +12 -2
  73. package/src/tweaker/tweak.ts +181 -18
  74. package/src/tweaker/tweakArray.ts +127 -77
  75. package/src/tweaker/tweakPlainObject.ts +65 -46
  76. package/src/tweaker/tweakedChangeListeners.ts +61 -0
  77. package/src/tweaker/typeChecking.ts +5 -104
  78. package/src/types/arrayBased/typesArray.ts +23 -30
  79. package/src/types/arrayBased/typesTuple.ts +16 -25
  80. package/src/types/createCachedTypeCheck.ts +509 -94
  81. package/src/types/index.ts +1 -0
  82. package/src/types/objectBased/typesDataModelData.ts +7 -8
  83. package/src/types/objectBased/typesObject.ts +11 -15
  84. package/src/types/objectBased/typesRecord.ts +12 -19
  85. package/src/types/schemas.ts +59 -71
  86. package/src/types/tProp.ts +29 -29
  87. package/src/types/utility/typeToStoredData.ts +22 -21
  88. package/src/types/utility/typesCodecCore.ts +5 -0
  89. package/src/types/utility/typesCodecSupport.ts +3 -3
  90. package/src/types/utility/typesMap.ts +43 -51
  91. package/src/types/utility/typesRefinement.ts +7 -8
  92. package/src/types/utility/typesSkipCheck.ts +7 -8
  93. package/src/types/utility/typesTag.ts +7 -8
  94. package/src/utils/ModelPool.ts +76 -37
  95. package/src/utils/chunks.ts +271 -0
  96. package/src/utils/decorators.ts +5 -5
  97. package/src/utils/index.ts +38 -20
  98. package/src/utils/types.ts +5 -0
  99. package/src/wrappers/ArraySet.ts +27 -14
  100. package/src/wrappers/ObjectMap.ts +1 -6
  101. package/src/wrappers/asMap.ts +1 -21
  102. package/src/wrappers/asSet.ts +0 -8
@@ -1,15 +1,28 @@
1
- import { action } from "mobx"
1
+ import { action, isObservableArray, isObservableObject } from "mobx"
2
2
  import { isDataModel } from "../dataModel/utils"
3
+ import { Frozen } from "../frozen/Frozen"
3
4
  import { isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig"
5
+ import { getModelMetadata } from "../model/getModelMetadata"
6
+ import { isModel } from "../model/utils"
4
7
  import { getObjectChildren } from "../parent/coreObjectChildren"
5
8
  import { fastGetParent, type ParentPath } from "../parent/path"
6
9
  import { setParent } from "../parent/setParent"
10
+ import { fastIsRootStoreNoAtom } from "../rootStore/rootStore"
7
11
  import { unsetInternalSnapshot } from "../snapshot/internal"
8
12
  import type { AnyStandardType, TypeToData } from "../types/schemas"
9
13
  import { typeCheck } from "../types/typeCheck"
10
- import { failure, inDevMode, isMap, isObject, isPrimitive, isSet } from "../utils"
14
+ import {
15
+ failure,
16
+ inDevMode,
17
+ isArray,
18
+ isMap,
19
+ isObject,
20
+ isPlainObject,
21
+ isPrimitive,
22
+ isSet,
23
+ } from "../utils"
11
24
  import { getSafeErrorValuePreview } from "../utils/errorDiagnostics"
12
- import { isTweakedObject, runTweakedObjectUntweakers } from "./core"
25
+ import { deactivateHandlers, isTweakedObject } from "./core"
13
26
  import { registerDefaultTweakers } from "./registerDefaultTweakers"
14
27
  import { treeNodeMetadata } from "./treeNodeMetadata"
15
28
 
@@ -100,9 +113,7 @@ function internalTweak<T>(value: T, parentPath: ParentPath<any> | undefined): T
100
113
 
101
114
  // unsupported (must go before plain object tweaker)
102
115
  if (isDataModel(value)) {
103
- throw failure(
104
- "data models are not directly supported. you may insert the data in the tree instead ('$' property)."
105
- )
116
+ throw dataModelsNotSupportedError()
106
117
  }
107
118
 
108
119
  registerDefaultTweakers()
@@ -116,17 +127,23 @@ function internalTweak<T>(value: T, parentPath: ParentPath<any> | undefined): T
116
127
  }
117
128
  }
118
129
 
119
- // unsupported
130
+ throw unsupportedValueError(value)
131
+ }
132
+
133
+ function dataModelsNotSupportedError() {
134
+ return failure(
135
+ "data models are not directly supported. you may insert the data in the tree instead ('$' property)."
136
+ )
137
+ }
138
+
139
+ function unsupportedValueError(value: unknown) {
120
140
  if (isMap(value)) {
121
- throw failure("maps are not directly supported. consider using 'ObjectMap' / 'asMap' instead.")
141
+ return failure("maps are not directly supported. consider using 'ObjectMap' / 'asMap' instead.")
122
142
  }
123
-
124
- // unsupported
125
143
  if (isSet(value)) {
126
- throw failure("sets are not directly supported. consider using 'ArraySet' / 'asSet' instead.")
144
+ return failure("sets are not directly supported. consider using 'ArraySet' / 'asSet' instead.")
127
145
  }
128
-
129
- throw failure(
146
+ return failure(
130
147
  `tweak can only work over models, observable objects/arrays, or primitives, but got ${getSafeErrorValuePreview(value)} instead`
131
148
  )
132
149
  }
@@ -141,6 +158,154 @@ export function tweak<T>(value: T, parentPath: ParentPath<any> | undefined): T {
141
158
  return isPrimitive(value) ? value : tweakNonPrimitive(value, parentPath)
142
159
  }
143
160
 
161
+ /**
162
+ * Tells whether a node that has a parent gets detached by the change being
163
+ * intercepted before the new values are attached.
164
+ * @internal
165
+ */
166
+ export type IsDetachedByChange = (node: object, parentPath: ParentPath<any>) => boolean
167
+
168
+ // nodes to be attached by the change being checked, since a node cannot be
169
+ // attached twice
170
+ const seenNodes = new Set<object>()
171
+
172
+ /**
173
+ * Throws the error `tweak` or `setParent` would throw when attaching `values`
174
+ * (and whatever they contain) after the change being intercepted detaches its
175
+ * values, without changing anything. Interceptors call it before detaching, so
176
+ * a rejected change leaves the tree as it was.
177
+ * `prepareValue`, when given, replaces each value before it is checked.
178
+ * @internal
179
+ */
180
+ export function assertCanAttach(
181
+ values: unknown[],
182
+ isDetachedByChange: IsDetachedByChange,
183
+ prepareValue?: (value: unknown) => unknown
184
+ ): void {
185
+ try {
186
+ for (let i = 0; i < values.length; i++) {
187
+ if (prepareValue) {
188
+ values[i] = prepareValue(values[i])
189
+ }
190
+ checkAttach(values[i], isDetachedByChange)
191
+ }
192
+ } finally {
193
+ seenNodes.clear()
194
+ }
195
+ }
196
+
197
+ /**
198
+ * `assertCanAttach` for a single value.
199
+ * @internal
200
+ */
201
+ export function assertCanAttachValue(value: unknown, isDetachedByChange: IsDetachedByChange): void {
202
+ if (isPrimitive(value)) {
203
+ return
204
+ }
205
+ try {
206
+ checkAttach(value, isDetachedByChange)
207
+ } finally {
208
+ seenNodes.clear()
209
+ }
210
+ }
211
+
212
+ function checkAttach(value: unknown, isDetachedByChange: IsDetachedByChange): void {
213
+ if (isPrimitive(value)) {
214
+ return
215
+ }
216
+
217
+ // (one metadata lookup instead of one per helper)
218
+ const metadata = treeNodeMetadata.get(value)
219
+ if (metadata?.tweaked) {
220
+ if (metadata.dataObjectParent !== undefined) {
221
+ throw failure("value must be the model object instance instead of the '$' sub-object")
222
+ }
223
+ if (fastIsRootStoreNoAtom(value)) {
224
+ throw failure("root stores cannot be attached to any parents")
225
+ }
226
+ if (isModel(value) && getModelMetadata(value).valueType) {
227
+ // setParent clones it when it has a parent
228
+ return
229
+ }
230
+ const parentPath = metadata.parentPath
231
+ if (
232
+ parentPath &&
233
+ !isDetachedByChange(value, parentPath) &&
234
+ !isUntweakedByChange(parentPath.parent, isDetachedByChange)
235
+ ) {
236
+ throw failure("an object cannot be assigned a new parent when it already has one")
237
+ }
238
+ assertNotSeen(value)
239
+ return
240
+ }
241
+
242
+ // the values internalTweak accepts, most common first
243
+ if (isPlainObject(value)) {
244
+ if (isObservableObject(value)) {
245
+ // (it becomes the tree node itself)
246
+ assertNotSeen(value)
247
+ }
248
+ checkAttachProps(value, isDetachedByChange)
249
+ return
250
+ }
251
+ if (isArray(value)) {
252
+ if (isObservableArray(value)) {
253
+ assertNotSeen(value)
254
+ }
255
+ for (let i = 0; i < value.length; i++) {
256
+ checkAttach(value[i], isDetachedByChange)
257
+ }
258
+ return
259
+ }
260
+ // (data models, frozen values and models can be observable objects too)
261
+ if (isDataModel(value)) {
262
+ throw dataModelsNotSupportedError()
263
+ }
264
+ if (value instanceof Frozen || isModel(value)) {
265
+ return
266
+ }
267
+ if (isObservableObject(value)) {
268
+ assertNotSeen(value)
269
+ checkAttachProps(value as Record<string, unknown>, isDetachedByChange)
270
+ return
271
+ }
272
+ throw unsupportedValueError(value)
273
+ }
274
+
275
+ function checkAttachProps(obj: Record<string, unknown>, isDetachedByChange: IsDetachedByChange) {
276
+ const keys = Object.keys(obj)
277
+ for (let i = 0; i < keys.length; i++) {
278
+ checkAttach(obj[keys[i]], isDetachedByChange)
279
+ }
280
+ }
281
+
282
+ function assertNotSeen(value: object) {
283
+ if (seenNodes.has(value)) {
284
+ throw failure("an object cannot be assigned a new parent when it already has one")
285
+ }
286
+ seenNodes.add(value)
287
+ }
288
+
289
+ /**
290
+ * Detaching a plain object or array untweaks it, which detaches its children.
291
+ */
292
+ function isUntweakedByChange(node: object, isDetachedByChange: IsDetachedByChange): boolean {
293
+ for (;;) {
294
+ const metadata = treeNodeMetadata.get(node)
295
+ if (!metadata?.handlersActive) {
296
+ return false
297
+ }
298
+ const parentPath = metadata.parentPath
299
+ if (!parentPath) {
300
+ return false
301
+ }
302
+ if (isDetachedByChange(node, parentPath)) {
303
+ return true
304
+ }
305
+ node = parentPath.parent
306
+ }
307
+ }
308
+
144
309
  /**
145
310
  * @internal
146
311
  */
@@ -156,8 +321,7 @@ export function tryUntweak(value: any): (() => void) | undefined {
156
321
  }
157
322
 
158
323
  const metadata = treeNodeMetadata.get(value)
159
- const untweaker = metadata?.untweaker
160
- if (!untweaker) {
324
+ if (!metadata?.handlersActive) {
161
325
  return undefined
162
326
  }
163
327
 
@@ -177,14 +341,13 @@ export function tryUntweak(value: any): (() => void) | undefined {
177
341
  }
178
342
 
179
343
  return () => {
180
- // post-untweaking, call the untweaker
181
- runTweakedObjectUntweakers(untweaker)
344
+ // post-untweaking
345
+ deactivateHandlers(value, metadata)
182
346
 
183
347
  // Match the former independent-map ordering: the node stops being tweaked
184
348
  // before its snapshot is unset, but the shared record must stay alive until
185
349
  // snapshot cleanup has consumed it.
186
350
  metadata.tweaked = false
187
- metadata.untweaker = undefined
188
351
  unsetInternalSnapshot(value)
189
352
  // These associations previously lived in independent WeakMaps and remain
190
353
  // valid across untweak/retweak, so keep their shared record when present.
@@ -11,7 +11,10 @@ import {
11
11
  import { assertCanWrite } from "../action/protection"
12
12
  import { emitArraySpliceDeepChange, emitArrayUpdateDeepChange } from "../deepChange/onDeepChange"
13
13
  import { getGlobalConfig } from "../globalConfig"
14
- import type { ParentPath } from "../parent/path"
14
+ import { getModelMetadata } from "../model/getModelMetadata"
15
+ import { isModel } from "../model/utils"
16
+ import { getArrayPaths, setLazyArrayParentPath } from "../parent/arrayPaths"
17
+ import { fastGetParentPath, type ParentPath } from "../parent/path"
15
18
  import { reindexArrayChildren, setParent } from "../parent/setParent"
16
19
  import {
17
20
  beginPatchEmission,
@@ -21,6 +24,7 @@ import {
21
24
  InternalPatchRecorder,
22
25
  } from "../patch/emitPatch"
23
26
  import type { Patch } from "../patch/Patch"
27
+ import { clone } from "../snapshot/clone"
24
28
  import {
25
29
  flushInternalSnapshot,
26
30
  freezeInternalSnapshot,
@@ -29,20 +33,20 @@ import {
29
33
  updateInternalSnapshot,
30
34
  } from "../snapshot/internal"
31
35
  import { failure, inDevMode, isArray, isPrimitive } from "../utils"
32
- import {
33
- addDelayedError,
34
- type DelayedError,
35
- throwDelayedError,
36
- } from "../utils/forEachWithDelayedThrow"
37
- import { runningWithoutSnapshotOrPatches, setTweakedObjectUntweakers } from "./core"
36
+ import { addDelayedError, type DelayedError } from "../utils/forEachWithDelayedThrow"
37
+ import { recordChangeForRollback, throwDelayedListenerError } from "./changeRollback"
38
+ import { activateHandlers, areHandlersActive, runningWithoutSnapshotOrPatches } from "./core"
38
39
  import { TweakerPriority } from "./TweakerPriority"
39
40
  import { markAsTweakedObject } from "./treeNodeMetadata"
40
- import { registerTweaker, tweak } from "./tweak"
41
41
  import {
42
- isTypeCheckingAfterChangeEnabled,
43
- recordTypeCheckingBatchChange,
44
- runTypeCheckingAfterChange,
45
- } from "./typeChecking"
42
+ assertCanAttach,
43
+ assertCanAttachValue,
44
+ type IsDetachedByChange,
45
+ registerTweaker,
46
+ tweak,
47
+ } from "./tweak"
48
+ import { notifyTweakedChangeListeners } from "./tweakedChangeListeners"
49
+ import { isTypeCheckingAfterChangeEnabled, runTypeCheckingAfterChange } from "./typeChecking"
46
50
 
47
51
  /**
48
52
  * @internal
@@ -139,27 +143,31 @@ export function tweakArray<T extends any[]>(
139
143
  }
140
144
  setNewInternalSnapshot(tweakedArr, untransformedSn, undefined)
141
145
 
142
- const interceptDisposer = intercept(
143
- tweakedArr,
144
- interceptArrayMutation.bind(undefined, tweakedArr)
145
- )
146
- const observeDisposer = observe(tweakedArr, arrayDidChange)
147
- setTweakedObjectUntweakers(tweakedArr, interceptDisposer, observeDisposer)
146
+ if (activateHandlers(tweakedArr)) {
147
+ intercept(tweakedArr, interceptArrayMutation.bind(undefined, tweakedArr))
148
+ observe(tweakedArr, arrayDidChange)
149
+ }
148
150
 
149
151
  return tweakedArr as any
150
152
  }
151
153
 
152
- function mutateSet(k: number, v: unknown, sn: unknown[]) {
154
+ function mutateSet(k: number, v: unknown, sn: unknown[]): readonly unknown[] {
153
155
  sn[k] = v
156
+ return [v]
154
157
  }
155
158
 
156
159
  // Conservative bound below every engine's argument-count limit for spread calls.
157
160
  const maxSpreadableItems = 8192
158
161
 
159
- function mutateSplice(index: number, removedCount: number, addedItems: any[], sn: any[]) {
162
+ function mutateSplice(
163
+ index: number,
164
+ removedCount: number,
165
+ addedItems: any[],
166
+ sn: any[]
167
+ ): readonly unknown[] {
160
168
  if (addedItems.length < maxSpreadableItems) {
161
169
  sn.splice(index, removedCount, ...addedItems)
162
- return
170
+ return addedItems
163
171
  }
164
172
 
165
173
  // Splice the tail in a single pass instead of spreading the added items as
@@ -170,11 +178,18 @@ function mutateSplice(index: number, removedCount: number, addedItems: any[], sn
170
178
  sn.copyWithin(index + addedItems.length, index + removedCount, oldLength)
171
179
  for (let i = 0; i < addedItems.length; i++) sn[index + i] = addedItems[i]
172
180
  sn.length = newLength
181
+ return addedItems
173
182
  }
174
183
 
175
184
  const patchRecorder = new InternalPatchRecorder()
176
185
 
177
186
  function arrayDidChange(change: IArrayDidChange) {
187
+ if (!areHandlersActive(change.object)) {
188
+ // idle handler of an untweaked array
189
+ return
190
+ }
191
+ notifyTweakedChangeListeners(change)
192
+
178
193
  const arr = change.object
179
194
 
180
195
  patchRecorder.reset()
@@ -183,13 +198,11 @@ function arrayDidChange(change: IArrayDidChange) {
183
198
  return
184
199
  }
185
200
 
186
- recordTypeCheckingBatchChange(change)
187
-
188
201
  const oldSnapshot = getInternalSnapshot(arr as Array<unknown>)!.untransformed
189
202
  const shouldEmitPatches = hasPatchListenersFor(arr)
190
203
  const shouldBuildInversePatches = shouldEmitPatches || isTypeCheckingAfterChangeEnabled()
191
204
 
192
- let mutate: ((sn: any[]) => void) | undefined
205
+ let mutate: ((sn: any[]) => readonly unknown[]) | undefined
193
206
  switch (change.type) {
194
207
  case "splice":
195
208
  mutate = arrayDidChangeSplice(
@@ -212,6 +225,8 @@ function arrayDidChange(change: IArrayDidChange) {
212
225
  }
213
226
 
214
227
  runTypeCheckingAfterChange(arr, patchRecorder)
228
+ // (after type checking, which undoes a rejected change, but before listeners)
229
+ recordChangeForRollback(change)
215
230
  if (mutate) {
216
231
  updateInternalSnapshot(arr, mutate)
217
232
  }
@@ -260,7 +275,7 @@ function arrayDidChange(change: IArrayDidChange) {
260
275
  } catch (error) {
261
276
  delayedError = addDelayedError(delayedError, error)
262
277
  }
263
- throwDelayedError(delayedError)
278
+ throwDelayedListenerError(delayedError)
264
279
  }
265
280
 
266
281
  const undefinedInsideArrayErrorMsg =
@@ -274,7 +289,6 @@ function arrayDidChangeUpdate(
274
289
  ) {
275
290
  const k = change.index
276
291
  const val = change.newValue
277
- const oldVal = oldSnapshot[k]
278
292
  let newVal: any
279
293
  if (isPrimitive(val)) {
280
294
  newVal = val
@@ -284,15 +298,14 @@ function arrayDidChangeUpdate(
284
298
  }
285
299
  const mutate = mutateSet.bind(undefined, k, newVal)
286
300
 
287
- if (shouldEmitPatches || shouldBuildInversePatches) {
301
+ if (shouldBuildInversePatches) {
288
302
  const path = [k]
289
303
  const patches = shouldEmitPatches
290
304
  ? [{ op: "replace" as const, path, value: freezeInternalSnapshot(newVal) }]
291
305
  : undefined
292
- const invPatches = shouldBuildInversePatches
293
- ? [{ op: "replace" as const, path, value: freezeInternalSnapshot(oldVal) }]
294
- : undefined
295
- patchRecorder.record(patches, invPatches)
306
+ patchRecorder.record(patches, [
307
+ { op: "replace" as const, path, value: freezeInternalSnapshot(oldSnapshot[k]) },
308
+ ])
296
309
  }
297
310
  return mutate
298
311
  }
@@ -320,17 +333,16 @@ function arrayDidChangeSplice(
320
333
 
321
334
  const mutate = mutateSplice.bind(undefined, index, removedCount, addedItems)
322
335
 
323
- // With neither patch listeners nor type checking, there is no inverse-patch
324
- // data to build. The snapshot mutation above is all the post-change work we
325
- // need to retain.
326
- if (!shouldEmitPatches && !shouldBuildInversePatches) {
336
+ // With neither patch listeners nor type checking, there is no patch data to
337
+ // build. The snapshot mutation above is all the post-change work we need.
338
+ if (!shouldBuildInversePatches) {
327
339
  return mutate
328
340
  }
329
341
 
330
342
  const oldLen = oldSnapshot.length
331
343
 
332
344
  const patches: Patch[] | undefined = shouldEmitPatches ? [] : undefined
333
- const invPatches: Patch[] | undefined = shouldBuildInversePatches ? [] : undefined
345
+ const invPatches: Patch[] = []
334
346
 
335
347
  // optimization: if we add as many as we remove then remove/readd instead
336
348
 
@@ -342,13 +354,13 @@ function arrayDidChangeSplice(
342
354
 
343
355
  if (addedCount === removedCount) {
344
356
  const readdPatches: Patch[] | undefined = shouldEmitPatches ? [] : undefined
345
- const readdInvPatches: Patch[] | undefined = shouldBuildInversePatches ? [] : undefined
357
+ const readdInvPatches: Patch[] = []
346
358
  let removed = 0
347
359
 
348
360
  for (let i = 0; i < addedCount; i++) {
349
361
  const realIndex = index + i
350
362
 
351
- const newVal = getValueAfterSplice(oldSnapshot, realIndex, index, removedCount, addedItems)
363
+ const newVal = addedItems[i]
352
364
  const oldVal = oldSnapshot[realIndex]
353
365
 
354
366
  if (newVal !== oldVal) {
@@ -357,7 +369,7 @@ function arrayDidChangeSplice(
357
369
  op: "remove",
358
370
  path: removePath,
359
371
  })
360
- invPatches?.push({
372
+ invPatches.push({
361
373
  op: "remove",
362
374
  path: removePath,
363
375
  })
@@ -371,7 +383,7 @@ function arrayDidChangeSplice(
371
383
  value: freezeInternalSnapshot(newVal),
372
384
  })
373
385
 
374
- readdInvPatches?.push({
386
+ readdInvPatches.push({
375
387
  op: "add",
376
388
  path: readdPath,
377
389
  value: freezeInternalSnapshot(oldVal),
@@ -379,17 +391,13 @@ function arrayDidChangeSplice(
379
391
  }
380
392
  }
381
393
 
382
- if (patches && readdPatches && readdPatches.length > 0) {
394
+ if (patches && readdPatches) {
383
395
  for (const patch of readdPatches) patches.push(patch)
384
396
  }
385
- if (invPatches && readdInvPatches && readdInvPatches.length > 0) {
386
- for (const patch of readdInvPatches) invPatches.push(patch)
387
- }
397
+ for (const patch of readdInvPatches) invPatches.push(patch)
388
398
  // We need to reverse once since inverse patches are applied in reverse.
389
399
  // Repeated unshift calls here would make a large splice quadratic.
390
- if (invPatches && invPatches.length > 0) {
391
- invPatches.reverse()
392
- }
400
+ invPatches.reverse()
393
401
  } else {
394
402
  const interimLen = oldLen - removedCount
395
403
 
@@ -418,7 +426,7 @@ function arrayDidChangeSplice(
418
426
  }
419
427
 
420
428
  // add 0, 1, 2... since inverse patches are applied in reverse
421
- invPatches?.push({
429
+ invPatches.push({
422
430
  op: "add",
423
431
  path,
424
432
  value: freezeInternalSnapshot(oldSnapshot[realIndex]),
@@ -431,7 +439,7 @@ function arrayDidChangeSplice(
431
439
  // optimization, for inverse patches, when adding from the end set the length to restore instead
432
440
  const restoreUsingSetLength = index >= interimLen
433
441
  if (restoreUsingSetLength) {
434
- invPatches?.push({
442
+ invPatches.push({
435
443
  op: "replace",
436
444
  path: ["length"],
437
445
  value: interimLen,
@@ -446,14 +454,12 @@ function arrayDidChangeSplice(
446
454
  patches?.push({
447
455
  op: "add",
448
456
  path,
449
- value: freezeInternalSnapshot(
450
- getValueAfterSplice(oldSnapshot, realIndex, index, removedCount, addedItems)
451
- ),
457
+ value: freezeInternalSnapshot(addedItems[i]),
452
458
  })
453
459
 
454
460
  // remove ...2, 1, 0 since inverse patches are applied in reverse
455
461
  if (!restoreUsingSetLength) {
456
- invPatches?.push({
462
+ invPatches.push({
457
463
  op: "remove",
458
464
  path,
459
465
  })
@@ -462,9 +468,7 @@ function arrayDidChangeSplice(
462
468
  }
463
469
  }
464
470
 
465
- if (patches || invPatches) {
466
- patchRecorder.record(patches, invPatches)
467
- }
471
+ patchRecorder.record(patches, invPatches)
468
472
  return mutate
469
473
  }
470
474
 
@@ -473,6 +477,10 @@ function interceptArrayMutation(
473
477
  array: IObservableArray,
474
478
  change: IArrayWillChange | IArrayWillSplice
475
479
  ) {
480
+ if (!areHandlersActive(array)) {
481
+ // idle handler of an untweaked array
482
+ return change
483
+ }
476
484
  assertCanWrite()
477
485
 
478
486
  switch (change.type) {
@@ -509,9 +517,18 @@ function validateArrayMutationUpdate(change: IArrayWillChange) {
509
517
  function interceptArrayMutationUpdate(change: IArrayWillChange, array: IObservableArray) {
510
518
  // TODO: should be change.object, but mobx is bugged and doesn't send the proxy
511
519
  const oldVal = array[change.index]
520
+ if (change.newValue === oldVal) {
521
+ return
522
+ }
523
+ assertCanAttachValue(change.newValue, (node) => node === oldVal)
512
524
  tweak(oldVal, undefined) // set old prop obj parent to undefined
513
525
 
514
- change.newValue = tweak(change.newValue, { parent: array, path: change.index })
526
+ const paths = getArrayPaths(array, 0)
527
+ const entry = paths?.set(change.index, change.newValue)
528
+ change.newValue = tweak(change.newValue, entry ?? { parent: array, path: change.index })
529
+ if (entry) {
530
+ setLazyArrayParentPath(entry, change.newValue)
531
+ }
515
532
  }
516
533
 
517
534
  function validateArrayMutationSplice(change: IArrayWillSplice) {
@@ -526,12 +543,65 @@ function validateArrayMutationSplice(change: IArrayWillSplice) {
526
543
  }
527
544
  }
528
545
 
546
+ /**
547
+ * Checks the added items can be attached before the splice detaches anything,
548
+ * so a rejected splice leaves every item as it was. Value types that are added
549
+ * to the array at the index they already have get cloned here, since
550
+ * `setParent` cannot tell they are being added again (it clones them when they
551
+ * are added anywhere else).
552
+ */
553
+ function checkAddedItems(change: IArrayWillSplice) {
554
+ const array = change.object
555
+ const added = change.added
556
+ const removedEnd = change.index + change.removedCount
557
+ const isDetachedByChange: IsDetachedByChange = (_node, parentPath) =>
558
+ parentPath.parent === array &&
559
+ (parentPath.path as number) >= change.index &&
560
+ (parentPath.path as number) < removedEnd
561
+
562
+ assertCanAttach(added, isDetachedByChange, (value) => {
563
+ if (!isPrimitive(value) && isModel(value) && getModelMetadata(value).valueType) {
564
+ const parentPath = fastGetParentPath(value, false)
565
+ if (parentPath?.parent === array && !isDetachedByChange(value, parentPath)) {
566
+ return clone(value, { generateNewIds: true })
567
+ }
568
+ }
569
+ return value
570
+ })
571
+ }
572
+
529
573
  function interceptArrayMutationSplice(change: IArrayWillSplice) {
574
+ checkAddedItems(change)
575
+
576
+ const array = change.object
577
+ const oldNextIndex = change.index + change.removedCount
578
+ const paths =
579
+ change.removedCount !== change.added.length
580
+ ? getArrayPaths(array, array.length - oldNextIndex)
581
+ : getArrayPaths(array, 0)
582
+
530
583
  for (let i = 0; i < change.removedCount; i++) {
531
584
  const removedValue = change.object[change.index + i]
532
585
  tweak(removedValue, undefined)
533
586
  }
534
587
 
588
+ if (paths) {
589
+ const addedPaths = paths.createPaths(change.index, change.added)
590
+ for (let i = 0; i < change.added.length; i++) {
591
+ const addedPath = addedPaths[i]
592
+ change.added[i] = tweak(
593
+ change.added[i],
594
+ addedPath ?? { parent: array, path: change.index + i }
595
+ )
596
+ if (addedPath) {
597
+ setLazyArrayParentPath(addedPath, change.added[i])
598
+ }
599
+ }
600
+ // no reindexing: the items after the splice follow their chunks
601
+ paths.applySplice(change.index, change.removedCount, addedPaths)
602
+ return
603
+ }
604
+
535
605
  for (let i = 0; i < change.added.length; i++) {
536
606
  change.added[i] = tweak(change.added[i], {
537
607
  parent: change.object,
@@ -540,7 +610,6 @@ function interceptArrayMutationSplice(change: IArrayWillSplice) {
540
610
  }
541
611
 
542
612
  // we might also need to update the parent of the next indexes
543
- const oldNextIndex = change.index + change.removedCount
544
613
  const newNextIndex = change.index + change.added.length
545
614
 
546
615
  if (oldNextIndex !== newNextIndex && oldNextIndex < change.object.length) {
@@ -596,22 +665,3 @@ class ObservableArrayReplacementQueue {
596
665
  }
597
666
  }
598
667
  }
599
-
600
- function getValueAfterSplice<T>(
601
- array: readonly T[],
602
- i: number,
603
- index: number,
604
- remove: number,
605
- addedItems: readonly T[]
606
- ) {
607
- const base = i - index
608
- if (base < 0) {
609
- return array[i]
610
- }
611
-
612
- if (base < addedItems.length) {
613
- return addedItems[base]
614
- }
615
-
616
- return array[i - addedItems.length + remove]
617
- }