mobx-keystone 2.0.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 (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/mobx-keystone.esm.js +2341 -780
  3. package/dist/mobx-keystone.esm.mjs +2341 -780
  4. package/dist/mobx-keystone.umd.js +2346 -782
  5. package/dist/types/actionMiddlewares/transactionMiddleware.d.ts +1 -1
  6. package/dist/types/actionMiddlewares/undoMiddleware.d.ts +17 -10
  7. package/dist/types/parent/arrayPaths.d.ts +1 -0
  8. package/dist/types/parent/treeOrder.d.ts +1 -0
  9. package/dist/types/patch/applyPatches.d.ts +2 -1
  10. package/dist/types/ref/modelRefIdIndex.d.ts +1 -0
  11. package/dist/types/ref/nodesById.d.ts +1 -0
  12. package/dist/types/ref/refIdIndex.d.ts +1 -0
  13. package/dist/types/snapshot/applySnapshot.d.ts +1 -0
  14. package/dist/types/snapshot/fromSnapshot.d.ts +0 -3
  15. package/dist/types/tweaker/changeRollback.d.ts +1 -0
  16. package/dist/types/tweaker/tweakedChangeListeners.d.ts +1 -0
  17. package/dist/types/utils/ModelPool.d.ts +7 -1
  18. package/dist/types/utils/chunks.d.ts +1 -0
  19. package/package.json +2 -2
  20. package/src/actionMiddlewares/transactionMiddleware.ts +40 -41
  21. package/src/actionMiddlewares/undoMiddleware.ts +47 -13
  22. package/src/model/BaseModel.ts +6 -4
  23. package/src/modelShared/modelDecorator.ts +5 -25
  24. package/src/parent/arrayPaths.ts +242 -0
  25. package/src/parent/coreObjectChildren.ts +300 -48
  26. package/src/parent/onChildAttachedTo.ts +130 -44
  27. package/src/parent/path.ts +10 -2
  28. package/src/parent/setParent.ts +27 -15
  29. package/src/parent/treeOrder.ts +53 -0
  30. package/src/parent/walkTree.ts +0 -73
  31. package/src/patch/applyPatches.ts +31 -26
  32. package/src/ref/core.ts +63 -27
  33. package/src/ref/modelRefIdIndex.ts +131 -0
  34. package/src/ref/nodesById.ts +60 -0
  35. package/src/ref/refIdIndex.ts +209 -0
  36. package/src/snapshot/applySnapshot.ts +26 -18
  37. package/src/snapshot/fromPlainObjectSnapshot.ts +3 -8
  38. package/src/snapshot/fromSnapshot.ts +2 -6
  39. package/src/snapshot/internal.ts +67 -10
  40. package/src/tweaker/changeRollback.ts +218 -0
  41. package/src/tweaker/core.ts +33 -20
  42. package/src/tweaker/treeNodeMetadata.ts +12 -2
  43. package/src/tweaker/tweak.ts +181 -18
  44. package/src/tweaker/tweakArray.ts +107 -27
  45. package/src/tweaker/tweakPlainObject.ts +56 -31
  46. package/src/tweaker/tweakedChangeListeners.ts +61 -0
  47. package/src/tweaker/typeChecking.ts +5 -104
  48. package/src/types/arrayBased/typesArray.ts +10 -11
  49. package/src/types/createCachedTypeCheck.ts +522 -94
  50. package/src/types/objectBased/typesRecord.ts +2 -5
  51. package/src/utils/ModelPool.ts +75 -19
  52. package/src/utils/chunks.ts +271 -0
  53. package/src/utils/index.ts +37 -0
@@ -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 =
@@ -473,6 +488,10 @@ function interceptArrayMutation(
473
488
  array: IObservableArray,
474
489
  change: IArrayWillChange | IArrayWillSplice
475
490
  ) {
491
+ if (!areHandlersActive(array)) {
492
+ // idle handler of an untweaked array
493
+ return change
494
+ }
476
495
  assertCanWrite()
477
496
 
478
497
  switch (change.type) {
@@ -509,9 +528,18 @@ function validateArrayMutationUpdate(change: IArrayWillChange) {
509
528
  function interceptArrayMutationUpdate(change: IArrayWillChange, array: IObservableArray) {
510
529
  // TODO: should be change.object, but mobx is bugged and doesn't send the proxy
511
530
  const oldVal = array[change.index]
531
+ if (change.newValue === oldVal) {
532
+ return
533
+ }
534
+ assertCanAttachValue(change.newValue, (node) => node === oldVal)
512
535
  tweak(oldVal, undefined) // set old prop obj parent to undefined
513
536
 
514
- change.newValue = tweak(change.newValue, { parent: array, path: change.index })
537
+ const paths = getArrayPaths(array, 0)
538
+ const entry = paths?.set(change.index, change.newValue)
539
+ change.newValue = tweak(change.newValue, entry ?? { parent: array, path: change.index })
540
+ if (entry) {
541
+ setLazyArrayParentPath(entry, change.newValue)
542
+ }
515
543
  }
516
544
 
517
545
  function validateArrayMutationSplice(change: IArrayWillSplice) {
@@ -526,12 +554,65 @@ function validateArrayMutationSplice(change: IArrayWillSplice) {
526
554
  }
527
555
  }
528
556
 
557
+ /**
558
+ * Checks the added items can be attached before the splice detaches anything,
559
+ * so a rejected splice leaves every item as it was. Value types that are added
560
+ * to the array at the index they already have get cloned here, since
561
+ * `setParent` cannot tell they are being added again (it clones them when they
562
+ * are added anywhere else).
563
+ */
564
+ function checkAddedItems(change: IArrayWillSplice) {
565
+ const array = change.object
566
+ const added = change.added
567
+ const removedEnd = change.index + change.removedCount
568
+ const isDetachedByChange: IsDetachedByChange = (_node, parentPath) =>
569
+ parentPath.parent === array &&
570
+ (parentPath.path as number) >= change.index &&
571
+ (parentPath.path as number) < removedEnd
572
+
573
+ assertCanAttach(added, isDetachedByChange, (value) => {
574
+ if (!isPrimitive(value) && isModel(value) && getModelMetadata(value).valueType) {
575
+ const parentPath = fastGetParentPath(value, false)
576
+ if (parentPath?.parent === array && !isDetachedByChange(value, parentPath)) {
577
+ return clone(value, { generateNewIds: true })
578
+ }
579
+ }
580
+ return value
581
+ })
582
+ }
583
+
529
584
  function interceptArrayMutationSplice(change: IArrayWillSplice) {
585
+ checkAddedItems(change)
586
+
587
+ const array = change.object
588
+ const oldNextIndex = change.index + change.removedCount
589
+ const paths =
590
+ change.removedCount !== change.added.length
591
+ ? getArrayPaths(array, array.length - oldNextIndex)
592
+ : getArrayPaths(array, 0)
593
+
530
594
  for (let i = 0; i < change.removedCount; i++) {
531
595
  const removedValue = change.object[change.index + i]
532
596
  tweak(removedValue, undefined)
533
597
  }
534
598
 
599
+ if (paths) {
600
+ const addedPaths = paths.createPaths(change.index, change.added)
601
+ for (let i = 0; i < change.added.length; i++) {
602
+ const addedPath = addedPaths[i]
603
+ change.added[i] = tweak(
604
+ change.added[i],
605
+ addedPath ?? { parent: array, path: change.index + i }
606
+ )
607
+ if (addedPath) {
608
+ setLazyArrayParentPath(addedPath, change.added[i])
609
+ }
610
+ }
611
+ // no reindexing: the items after the splice follow their chunks
612
+ paths.applySplice(change.index, change.removedCount, addedPaths)
613
+ return
614
+ }
615
+
535
616
  for (let i = 0; i < change.added.length; i++) {
536
617
  change.added[i] = tweak(change.added[i], {
537
618
  parent: change.object,
@@ -540,7 +621,6 @@ function interceptArrayMutationSplice(change: IArrayWillSplice) {
540
621
  }
541
622
 
542
623
  // we might also need to update the parent of the next indexes
543
- const oldNextIndex = change.index + change.removedCount
544
624
  const newNextIndex = change.index + change.added.length
545
625
 
546
626
  if (oldNextIndex !== newNextIndex && oldNextIndex < change.object.length) {
@@ -13,10 +13,12 @@ import {
13
13
  emitObjectUpdateDeepChange,
14
14
  } from "../deepChange/onDeepChange"
15
15
  import type { AnyModel } from "../model/BaseModel"
16
+ import { getModelIdPropertyName } from "../model/getModelMetadata"
16
17
  import { modelTypeKey } from "../model/metadata"
17
18
  import type { ModelClass } from "../modelShared/BaseModelShared"
18
19
  import { getModelInfoForName, getModelNotRegisteredErrorMessage } from "../modelShared/modelInfo"
19
20
  import { dataToModelNode } from "../parent/core"
21
+ import { onModelIdChanged } from "../parent/coreObjectChildren"
20
22
  import type { ParentPath } from "../parent/path"
21
23
  import { setParent } from "../parent/setParent"
22
24
  import {
@@ -36,22 +38,16 @@ import {
36
38
  } from "../snapshot/internal"
37
39
  import { takeModelInitialDataSnapshot } from "../snapshot/modelInitialData"
38
40
  import { withoutCachedTypeChecking } from "../types/createCachedTypeCheck"
39
- import { failure, isPlainObject, isPrimitive, setProtoProp } from "../utils"
40
- import {
41
- addDelayedError,
42
- type DelayedError,
43
- throwDelayedError,
44
- } from "../utils/forEachWithDelayedThrow"
41
+ import { failure, isPlainObject, isPrimitive, nodeObservableOptions, setProtoProp } from "../utils"
42
+ import { addDelayedError, type DelayedError } from "../utils/forEachWithDelayedThrow"
45
43
  import { setIfDifferent } from "../utils/setIfDifferent"
46
- import { runningWithoutSnapshotOrPatches, setTweakedObjectUntweakers } from "./core"
44
+ import { recordChangeForRollback, throwDelayedListenerError } from "./changeRollback"
45
+ import { activateHandlers, runningWithoutSnapshotOrPatches } from "./core"
47
46
  import { TweakerPriority } from "./TweakerPriority"
48
- import { markAsTweakedObject } from "./treeNodeMetadata"
49
- import { registerTweaker, tweak } from "./tweak"
50
- import {
51
- isTypeCheckingAfterChangeEnabled,
52
- recordTypeCheckingBatchChange,
53
- runTypeCheckingAfterChange,
54
- } from "./typeChecking"
47
+ import { markAsTweakedObject, treeNodeMetadata } from "./treeNodeMetadata"
48
+ import { assertCanAttachValue, registerTweaker, tweak } from "./tweak"
49
+ import { notifyTweakedChangeListeners } from "./tweakedChangeListeners"
50
+ import { isTypeCheckingAfterChangeEnabled, runTypeCheckingAfterChange } from "./typeChecking"
55
51
 
56
52
  /**
57
53
  * @internal
@@ -67,7 +63,7 @@ export function tweakPlainObject<T extends Record<string, any>>(
67
63
  const originalIsObservableObject = isObservableObject(originalObj)
68
64
  const tweakedObj = originalIsObservableObject
69
65
  ? originalObj
70
- : observable.object({}, undefined, observableOptions)
66
+ : observable.object({}, undefined, nodeObservableOptions)
71
67
 
72
68
  // Mark it as tweaked before processing children. The cleanup functions are
73
69
  // installed after the MobX handlers have been registered.
@@ -150,38 +146,57 @@ export function tweakPlainObject<T extends Record<string, any>>(
150
146
  transformFn
151
147
  )
152
148
 
153
- const interceptDisposer = intercept(tweakedObj, interceptObjectMutation)
154
- const observeDisposer = observe(tweakedObj, objectDidChange)
155
- setTweakedObjectUntweakers(tweakedObj, interceptDisposer, observeDisposer)
149
+ if (activateHandlers(tweakedObj)) {
150
+ intercept(tweakedObj, interceptObjectMutation)
151
+ observe(tweakedObj, objectDidChange)
152
+ }
156
153
 
157
154
  return tweakedObj as any
158
155
  }
159
156
 
160
- const observableOptions = {
161
- deep: false,
162
- }
163
-
164
- function mutateSet(k: PropertyKey, v: unknown, sn: Record<PropertyKey, unknown>) {
157
+ function mutateSet(
158
+ k: PropertyKey,
159
+ v: unknown,
160
+ sn: Record<PropertyKey, unknown>
161
+ ): readonly unknown[] {
165
162
  if (k === "__proto__") {
166
163
  setProtoProp(sn, v)
167
164
  } else {
168
165
  sn[k] = v
169
166
  }
167
+ return [v]
170
168
  }
171
169
 
172
- function mutateDelete(k: PropertyKey, sn: Record<PropertyKey, unknown>) {
170
+ const noValues: readonly unknown[] = []
171
+
172
+ function mutateDelete(k: PropertyKey, sn: Record<PropertyKey, unknown>): readonly unknown[] {
173
173
  delete sn[k]
174
+ return noValues
174
175
  }
175
176
 
176
177
  const patchRecorder = new InternalPatchRecorder()
177
178
 
178
179
  function objectDidChange(change: IObjectDidChange): void {
180
+ const obj = change.object
181
+ const metadata = treeNodeMetadata.get(obj)
182
+ if (!metadata?.handlersActive) {
183
+ // idle handler of an untweaked object
184
+ return
185
+ }
179
186
  if (typeof change.name !== "string") {
180
187
  throw failure("change.name is not a string")
181
188
  }
182
189
 
183
- const obj = change.object
184
- const actualNode = dataToModelNode(obj)
190
+ notifyTweakedChangeListeners(change)
191
+
192
+ const actualNode = metadata.dataObjectParent ?? obj
193
+
194
+ if (
195
+ actualNode !== obj &&
196
+ change.name === getModelIdPropertyName(actualNode.constructor as ModelClass<AnyModel>)
197
+ ) {
198
+ onModelIdChanged(actualNode)
199
+ }
185
200
 
186
201
  patchRecorder.reset()
187
202
 
@@ -189,13 +204,11 @@ function objectDidChange(change: IObjectDidChange): void {
189
204
  return
190
205
  }
191
206
 
192
- recordTypeCheckingBatchChange(change)
193
-
194
207
  const oldUntransformedSn = getInternalSnapshot(actualNode)!.untransformed
195
208
  const shouldEmitPatches = hasPatchListenersFor(actualNode)
196
209
  const shouldBuildInversePatches = shouldEmitPatches || isTypeCheckingAfterChangeEnabled()
197
210
 
198
- let mutate: ((sn: any) => void) | undefined
211
+ let mutate: ((sn: any) => readonly unknown[]) | undefined
199
212
  switch (change.type) {
200
213
  case "add":
201
214
  case "update":
@@ -226,6 +239,8 @@ function objectDidChange(change: IObjectDidChange): void {
226
239
  } else {
227
240
  runTypeCheckingAfterChange(obj, patchRecorder)
228
241
  }
242
+ // (after type checking, which undoes a rejected change, but before listeners)
243
+ recordChangeForRollback(change)
229
244
  updateInternalSnapshot(actualNode, mutate)
230
245
 
231
246
  // Listeners may mutate another node and reuse the shared recorder.
@@ -270,7 +285,7 @@ function objectDidChange(change: IObjectDidChange): void {
270
285
  } catch (error) {
271
286
  delayedError = addDelayedError(delayedError, error)
272
287
  }
273
- throwDelayedError(delayedError)
288
+ throwDelayedListenerError(delayedError)
274
289
  }
275
290
 
276
291
  function objectDidChangeRemove(
@@ -337,7 +352,14 @@ function objectDidChangeAddOrUpdate(
337
352
  return mutate
338
353
  }
339
354
 
355
+ const noneDetached = () => false
356
+
340
357
  function interceptObjectMutation(change: IObjectWillChange) {
358
+ const metadata = treeNodeMetadata.get(change.object)
359
+ if (!metadata?.handlersActive) {
360
+ // idle handler of an untweaked object
361
+ return change
362
+ }
341
363
  assertCanWrite()
342
364
 
343
365
  if (typeof change.name === "symbol") {
@@ -347,10 +369,11 @@ function interceptObjectMutation(change: IObjectWillChange) {
347
369
  // This must happen before detach/reparent work below. It keeps the old value
348
370
  // available for inverse patches and folds a departing dirty child into its
349
371
  // parent snapshot while its parent path still exists.
350
- flushInternalSnapshot(dataToModelNode(change.object), false)
372
+ flushInternalSnapshot(metadata.dataObjectParent ?? change.object, false)
351
373
 
352
374
  switch (change.type) {
353
375
  case "add":
376
+ assertCanAttachValue(change.newValue, noneDetached)
354
377
  change.newValue = tweak(change.newValue, {
355
378
  parent: change.object,
356
379
  path: String(change.name),
@@ -366,6 +389,8 @@ function interceptObjectMutation(change: IObjectWillChange) {
366
389
  const oldVal = change.object[change.name]
367
390
  const newVal = change.newValue
368
391
  if (newVal !== oldVal) {
392
+ // (first, so a value that cannot be attached leaves the old one in place)
393
+ assertCanAttachValue(newVal, (node) => node === oldVal)
369
394
  tweak(oldVal, undefined)
370
395
 
371
396
  change.newValue = tweak(newVal, {