mobx-keystone 1.25.0 → 2.0.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 (115) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/mobx-keystone.esm.js +1478 -952
  3. package/dist/mobx-keystone.esm.mjs +1478 -952
  4. package/dist/mobx-keystone.umd.js +1483 -952
  5. package/dist/types/actionMiddlewares/actionSerialization/dateSerializer.d.ts +1 -1
  6. package/dist/types/dataModel/dataModelInstanceCache.d.ts +1 -0
  7. package/dist/types/deepChange/onDeepChange.d.ts +9 -0
  8. package/dist/types/parent/detach.d.ts +1 -1
  9. package/dist/types/parent/findChildren.d.ts +1 -1
  10. package/dist/types/patch/applyPatches.d.ts +2 -0
  11. package/dist/types/redux/connectReduxDevTools.d.ts +2 -1
  12. package/dist/types/snapshot/SnapshotOf.d.ts +2 -2
  13. package/dist/types/standardActions/arrayActions.d.ts +1 -1
  14. package/dist/types/treeUtils/sandbox.d.ts +0 -4
  15. package/dist/types/types/TypeCheckError.d.ts +1 -0
  16. package/dist/types/types/utility/typesRefinement.d.ts +2 -2
  17. package/dist/types/utils/forEachWithDelayedThrow.d.ts +1 -0
  18. package/dist/types/utils/index.d.ts +13 -0
  19. package/dist/types/utils/mapUtils.d.ts +2 -2
  20. package/dist/types/utils/setLike.d.ts +1 -0
  21. package/package.json +1 -1
  22. package/src/action/applyAction.ts +1 -1
  23. package/src/action/middleware.ts +17 -36
  24. package/src/action/modelFlow.ts +4 -5
  25. package/src/action/mutationBatch.ts +10 -10
  26. package/src/action/pendingActions.ts +3 -4
  27. package/src/actionMiddlewares/actionSerialization/actionSerialization.ts +19 -14
  28. package/src/actionMiddlewares/actionSerialization/applySerializedAction.ts +29 -20
  29. package/src/actionMiddlewares/actionSerialization/arraySerializer.ts +1 -1
  30. package/src/actionMiddlewares/actionSerialization/dateSerializer.ts +4 -3
  31. package/src/actionMiddlewares/actionSerialization/mapSerializer.ts +4 -12
  32. package/src/actionMiddlewares/actionSerialization/setSerializer.ts +4 -13
  33. package/src/actionMiddlewares/actionTrackingMiddleware.ts +11 -13
  34. package/src/actionMiddlewares/transactionMiddleware.ts +42 -11
  35. package/src/actionMiddlewares/undoMiddleware.ts +65 -46
  36. package/src/actionMiddlewares/utils.ts +2 -1
  37. package/src/computedTree/computedTree.ts +14 -3
  38. package/src/context/context.ts +17 -45
  39. package/src/dataModel/BaseDataModel.ts +20 -16
  40. package/src/dataModel/dataModelInstanceCache.ts +8 -0
  41. package/src/deepChange/onDeepChange.ts +179 -24
  42. package/src/frozen/Frozen.ts +50 -33
  43. package/src/model/BaseModel.ts +4 -9
  44. package/src/model/newModel.ts +21 -24
  45. package/src/model/utils.ts +2 -2
  46. package/src/modelShared/modelClassInitializer.ts +3 -2
  47. package/src/modelShared/modelDecorator.ts +34 -14
  48. package/src/modelShared/modelInfo.ts +1 -1
  49. package/src/modelShared/prop.ts +6 -9
  50. package/src/modelShared/sharedInternalModel.ts +16 -3
  51. package/src/parent/detach.ts +1 -1
  52. package/src/parent/findChildren.ts +4 -7
  53. package/src/parent/onChildAttachedTo.ts +44 -28
  54. package/src/parent/path.ts +5 -3
  55. package/src/parent/setParent.ts +7 -0
  56. package/src/parent/walkTree.ts +21 -37
  57. package/src/patch/applyPatches.ts +69 -34
  58. package/src/patch/emitPatch.ts +142 -26
  59. package/src/patch/jsonPatch.ts +5 -0
  60. package/src/patch/patchRecorder.ts +18 -14
  61. package/src/redux/connectReduxDevTools.ts +24 -18
  62. package/src/redux/redux.ts +5 -21
  63. package/src/ref/core.ts +18 -2
  64. package/src/rootStore/attachDetach.ts +27 -16
  65. package/src/rootStore/rootStore.ts +10 -6
  66. package/src/snapshot/SnapshotOf.ts +2 -2
  67. package/src/snapshot/applySnapshot.ts +1 -1
  68. package/src/snapshot/clone.ts +1 -2
  69. package/src/snapshot/cloneTreeValue.ts +1 -2
  70. package/src/snapshot/fromModelSnapshot.ts +6 -2
  71. package/src/snapshot/internal.ts +22 -9
  72. package/src/snapshot/onSnapshot.ts +12 -1
  73. package/src/snapshot/reconcileModelSnapshot.ts +6 -6
  74. package/src/snapshot/reconcilePlainObjectSnapshot.ts +5 -4
  75. package/src/standardActions/arrayActions.ts +12 -2
  76. package/src/standardActions/objectActions.ts +3 -2
  77. package/src/transforms/ImmutableDate.ts +4 -0
  78. package/src/transforms/asMap.ts +3 -1
  79. package/src/treeUtils/deepEquals.ts +15 -13
  80. package/src/treeUtils/draft.ts +8 -0
  81. package/src/treeUtils/sandbox.ts +31 -27
  82. package/src/tweaker/core.ts +2 -4
  83. package/src/tweaker/tweak.ts +2 -1
  84. package/src/tweaker/tweakArray.ts +83 -20
  85. package/src/tweaker/tweakPlainObject.ts +76 -23
  86. package/src/tweaker/typeChecking.ts +124 -15
  87. package/src/types/TypeCheckError.ts +17 -19
  88. package/src/types/createCachedTypeCheck.ts +27 -2
  89. package/src/types/objectBased/typesDataModelData.ts +4 -2
  90. package/src/types/objectBased/typesModel.ts +4 -2
  91. package/src/types/objectBased/typesObject.ts +20 -16
  92. package/src/types/objectBased/typesRecord.ts +6 -13
  93. package/src/types/objectBased/typesRef.ts +2 -2
  94. package/src/types/primitiveBased/typesEnum.ts +4 -2
  95. package/src/types/primitiveBased/typesPrimitive.ts +9 -4
  96. package/src/types/typeCheckErrorUtils.ts +1 -0
  97. package/src/types/utility/typesCodecSupport.ts +97 -56
  98. package/src/types/utility/typesMap.ts +24 -41
  99. package/src/types/utility/typesRefinement.ts +3 -2
  100. package/src/types/utility/typesSet.ts +24 -0
  101. package/src/utils/ModelPool.ts +3 -3
  102. package/src/utils/chainFns.ts +4 -0
  103. package/src/utils/decorators.ts +1 -1
  104. package/src/utils/errorDiagnostics.ts +6 -2
  105. package/src/utils/forEachWithDelayedThrow.ts +83 -0
  106. package/src/utils/index.ts +34 -2
  107. package/src/utils/mapUtils.ts +3 -7
  108. package/src/utils/setIfDifferent.ts +3 -2
  109. package/src/utils/setLike.ts +134 -0
  110. package/src/wrappers/ArraySet.ts +5 -9
  111. package/src/wrappers/ObjectMap.ts +15 -11
  112. package/src/wrappers/asMap.ts +30 -18
  113. package/src/wrappers/asSet.ts +36 -12
  114. package/dist/types/action/modelFlowPromiseGenerator.d.ts +0 -1
  115. package/src/action/modelFlowPromiseGenerator.ts +0 -127
@@ -2,7 +2,8 @@ import { action, isAction } from "mobx"
2
2
  import { fastGetParentPath, type ParentPath } from "../parent/path"
3
3
  import type { Path, PathElement, WritablePath } from "../parent/pathTypes"
4
4
  import { assertTweakedObject } from "../tweaker/core"
5
- import { assertIsFunction, deleteFromArray, failure } from "../utils"
5
+ import { assertIsFunction, failure } from "../utils"
6
+ import { addDelayedError, type DelayedError } from "../utils/forEachWithDelayedThrow"
6
7
 
7
8
  /**
8
9
  * Disposer function to stop listening to deep changes.
@@ -18,6 +19,8 @@ export enum DeepChangeType {
18
19
  }
19
20
 
20
21
  export interface DeepChangeBase {
22
+ /** Whether this notification overlaps a mutation made by a change listener. */
23
+ readonly isReentrant?: boolean
21
24
  readonly type: DeepChangeType
22
25
  readonly path: Path
23
26
  /**
@@ -97,8 +100,17 @@ export type DeepChange =
97
100
  */
98
101
  export type OnDeepChangeListener = (change: DeepChange) => void
99
102
 
100
- const deepChangeListeners = new WeakMap<object, OnDeepChangeListener[]>()
101
- const globalDeepChangeListeners: ((target: object, change: DeepChange) => void)[] = []
103
+ interface ListenerSubscription<Listener> {
104
+ readonly listener: Listener
105
+ active: boolean
106
+ }
107
+
108
+ const deepChangeListeners = new WeakMap<object, ListenerSubscription<OnDeepChangeListener>[]>()
109
+
110
+ let globalDeepChangeListeners: ListenerSubscription<
111
+ (target: object, change: DeepChange) => void
112
+ >[] = []
113
+
102
114
  const emptyPath: Path = Object.freeze([])
103
115
 
104
116
  let deepChangeListenerCount = 0
@@ -143,6 +155,56 @@ export function exitInitPhase(): void {
143
155
  }
144
156
  }
145
157
 
158
+ interface DeepChangeEmission {
159
+ reentrant: boolean
160
+ /** Failures thrown by the listeners of this emission, reported once it has been delivered. */
161
+ error: DelayedError
162
+ parent: DeepChangeEmission | undefined
163
+ /** Shared by every change of this emission, so they all observe `reentrant` live. */
164
+ isReentrantDescriptor: PropertyDescriptor | undefined
165
+ }
166
+
167
+ let currentEmission: DeepChangeEmission | undefined
168
+
169
+ function rethrowDeepChangeListenerError(emission: DeepChangeEmission): void {
170
+ const error = emission.error
171
+ if (error) {
172
+ // Listeners may keep change objects around, and those keep this emission alive
173
+ // through their `isReentrant` getter, so don't retain the errors with it.
174
+ emission.error = undefined
175
+ throw error.value
176
+ }
177
+ }
178
+
179
+ function beginDeepChangeEmission(): DeepChangeEmission {
180
+ for (let parent = currentEmission; parent; parent = parent.parent) parent.reentrant = true
181
+ const emission: DeepChangeEmission = {
182
+ reentrant: currentEmission !== undefined,
183
+ error: undefined,
184
+ parent: currentEmission,
185
+ isReentrantDescriptor: undefined,
186
+ }
187
+ currentEmission = emission
188
+ return emission
189
+ }
190
+
191
+ // `isReentrant` is deliberately non-enumerable so that object spread and JSON
192
+ // serialization of a change do not copy it.
193
+ function attachEmission(change: DeepChange): DeepChange {
194
+ const emission = currentEmission!
195
+ emission.isReentrantDescriptor ??= {
196
+ get: () => emission.reentrant,
197
+ configurable: true,
198
+ }
199
+ Object.defineProperty(change, "isReentrant", emission.isReentrantDescriptor)
200
+ return change
201
+ }
202
+
203
+ function changeWithPath(change: DeepChange, path: Path): DeepChange {
204
+ // The copy belongs to the same (still current) emission as the original.
205
+ return attachEmission({ ...change, path })
206
+ }
207
+
146
208
  /**
147
209
  * @internal
148
210
  */
@@ -151,10 +213,17 @@ export function emitDeepChange(obj: object, change: DeepChange): void {
151
213
  return
152
214
  }
153
215
 
154
- emitGlobalDeepChange(obj, change)
155
- if (deepChangeListenerCount > 0) {
156
- emitDeepChangeToListeners(obj, change)
216
+ const emission = beginDeepChangeEmission()
217
+ try {
218
+ attachEmission(change)
219
+ emitGlobalDeepChange(obj, change)
220
+ if (deepChangeListenerCount > 0) {
221
+ emitDeepChangeToListeners(obj, change)
222
+ }
223
+ } finally {
224
+ currentEmission = emission.parent
157
225
  }
226
+ rethrowDeepChangeListenerError(emission)
158
227
  }
159
228
 
160
229
  /**
@@ -238,6 +307,38 @@ function emitDeepChangeFromValues(
238
307
  oldValue: unknown,
239
308
  addedValues?: unknown[],
240
309
  removedValues?: unknown[]
310
+ ): void {
311
+ if (!hasAnyDeepChangeListeners()) {
312
+ return
313
+ }
314
+
315
+ const emission = beginDeepChangeEmission()
316
+ try {
317
+ emitDeepChangeFromValuesInEmission(
318
+ node,
319
+ type,
320
+ target,
321
+ keyOrIndex,
322
+ newValue,
323
+ oldValue,
324
+ addedValues,
325
+ removedValues
326
+ )
327
+ } finally {
328
+ currentEmission = emission.parent
329
+ }
330
+ rethrowDeepChangeListenerError(emission)
331
+ }
332
+
333
+ function emitDeepChangeFromValuesInEmission(
334
+ node: object,
335
+ type: DeepChangeType,
336
+ target: object | unknown[],
337
+ keyOrIndex: string | number,
338
+ newValue: unknown,
339
+ oldValue: unknown,
340
+ addedValues?: unknown[],
341
+ removedValues?: unknown[]
241
342
  ): void {
242
343
  let change: DeepChange | undefined
243
344
 
@@ -295,6 +396,20 @@ function createDeepChange(
295
396
  oldValue: unknown,
296
397
  addedValues: unknown[] | undefined,
297
398
  removedValues: unknown[] | undefined
399
+ ): DeepChange {
400
+ return attachEmission(
401
+ buildDeepChange(type, target, keyOrIndex, newValue, oldValue, addedValues, removedValues)
402
+ )
403
+ }
404
+
405
+ function buildDeepChange(
406
+ type: DeepChangeType,
407
+ target: object | unknown[],
408
+ keyOrIndex: string | number,
409
+ newValue: unknown,
410
+ oldValue: unknown,
411
+ addedValues: unknown[] | undefined,
412
+ removedValues: unknown[] | undefined
298
413
  ): DeepChange {
299
414
  const isInit = getIsInInitPhase()
300
415
  switch (type) {
@@ -352,12 +467,28 @@ function createDeepChange(
352
467
  }
353
468
 
354
469
  function emitGlobalDeepChange(obj: object, change: DeepChange): void {
355
- for (let i = 0; i < globalDeepChangeListeners.length; i++) {
356
- const listener = globalDeepChangeListeners[i]
357
- listener(obj, change)
470
+ const listeners = globalDeepChangeListeners
471
+ for (let i = 0; i < listeners.length; i++) {
472
+ const subscription = listeners[i]
473
+ if (subscription.active) {
474
+ try {
475
+ subscription.listener(obj, change)
476
+ } catch (error) {
477
+ rememberDeepChangeListenerError(error)
478
+ }
479
+ }
358
480
  }
359
481
  }
360
482
 
483
+ // The mutation is already applied by the time listeners run, so a failing listener
484
+ // must not keep it from reaching bindings and other observers. Once the whole emission
485
+ // has been delivered a lone failure is rethrown as is, and several failures are grouped
486
+ // in a MobxKeystoneAggregateError.
487
+ function rememberDeepChangeListenerError(error: unknown): void {
488
+ const emission = currentEmission!
489
+ emission.error = addDelayedError(emission.error, error, "multiple deep change listeners failed")
490
+ }
491
+
361
492
  function emitDeepChangeToListeners(obj: object, change: DeepChange): void {
362
493
  // Segments are pushed in child-to-root order (O(1) each) rather than unshifted
363
494
  // at the front (O(depth) each), keeping the whole walk O(depth) instead of
@@ -391,14 +522,20 @@ function emitDeepChangeForTarget(
391
522
  return
392
523
  }
393
524
 
394
- const changeWithPath =
525
+ const prefixedChange =
395
526
  prefixCount > 0
396
- ? { ...change, path: buildPrefixedPath(reversedPrefix, prefixCount, change.path) }
527
+ ? changeWithPath(change, buildPrefixedPath(reversedPrefix, prefixCount, change.path))
397
528
  : change
398
529
 
399
530
  for (let i = 0; i < listenersForObject.length; i++) {
400
- const listener = listenersForObject[i]
401
- listener(changeWithPath)
531
+ const subscription = listenersForObject[i]
532
+ if (subscription.active) {
533
+ try {
534
+ subscription.listener(prefixedChange)
535
+ } catch (error) {
536
+ rememberDeepChangeListenerError(error)
537
+ }
538
+ }
402
539
  }
403
540
  }
404
541
 
@@ -420,6 +557,13 @@ function buildPrefixedPath(
420
557
  /**
421
558
  * Adds a listener that will be called every time a deep change is generated for the tree of the given target object.
422
559
  * Unlike `onPatches`, this provides raw MobX change information including proper splice detection for arrays.
560
+ * Individual mutations notify listeners after automatic type checking succeeds and snapshots update;
561
+ * rejected mutations and their quiet rollbacks do not notify listeners.
562
+ * Listener errors do not stop delivery to other listeners. After delivery completes,
563
+ * a single error is rethrown unchanged and multiple errors are grouped in a MobxKeystoneAggregateError.
564
+ * Listeners added while callbacks for the same target are running start with the next
565
+ * change delivered to that target. Listeners disposed during delivery do not receive
566
+ * the current change if their callback has not run yet.
423
567
  *
424
568
  * @param subtreeRoot Subtree root object of the deep change listener.
425
569
  * @param listener The listener function that will be called every time a change is generated for the object or its children.
@@ -436,18 +580,23 @@ export function onDeepChange(
436
580
  listener = action(listener.name || "onDeepChangeListener", listener)
437
581
  }
438
582
 
439
- let listenersForObject = deepChangeListeners.get(subtreeRoot)
440
- if (!listenersForObject) {
441
- listenersForObject = []
442
- deepChangeListeners.set(subtreeRoot, listenersForObject)
443
- }
444
-
445
- listenersForObject.push(listener)
583
+ // Keep the array held by an ongoing delivery unchanged. Subscription changes
584
+ // are infrequent, so copy here rather than on every mutation notification.
585
+ const listeners = deepChangeListeners.get(subtreeRoot) ?? []
586
+ const subscription = { listener, active: true }
587
+ deepChangeListeners.set(subtreeRoot, [...listeners, subscription])
446
588
  deepChangeListenerCount++
589
+
447
590
  return () => {
448
- if (deleteFromArray(listenersForObject, listener)) {
449
- deepChangeListenerCount--
591
+ if (!subscription.active) {
592
+ return
450
593
  }
594
+ subscription.active = false
595
+ deepChangeListenerCount--
596
+ deepChangeListeners.set(
597
+ subtreeRoot,
598
+ deepChangeListeners.get(subtreeRoot)!.filter((s) => s !== subscription)
599
+ )
451
600
  }
452
601
  }
453
602
 
@@ -467,8 +616,14 @@ export function onGlobalDeepChange(
467
616
  listener = action(listener.name || "onGlobalDeepChangeListener", listener)
468
617
  }
469
618
 
470
- globalDeepChangeListeners.push(listener)
619
+ const subscription = { listener, active: true }
620
+ globalDeepChangeListeners = [...globalDeepChangeListeners, subscription]
621
+
471
622
  return () => {
472
- deleteFromArray(globalDeepChangeListeners, listener)
623
+ if (!subscription.active) {
624
+ return
625
+ }
626
+ subscription.active = false
627
+ globalDeepChangeListeners = globalDeepChangeListeners.filter((s) => s !== subscription)
473
628
  }
474
629
  }
@@ -3,6 +3,7 @@ import { getGlobalConfig } from "../globalConfig"
3
3
  import type { FrozenData } from "../snapshot"
4
4
  import { tweak } from "../tweaker/tweak"
5
5
  import { failure, inDevMode, isPlainObject, isPrimitive } from "../utils"
6
+ import { getSafeErrorValuePreview } from "../utils/errorDiagnostics"
6
7
 
7
8
  /**
8
9
  * Should freeze and plain json checks be done when creating the frozen object?
@@ -54,7 +55,7 @@ export class Frozen<T> {
54
55
  this.data = dataToFreeze
55
56
 
56
57
  if (check) {
57
- Object.freeze(this.data)
58
+ Object.defineProperty(this, "data", { writable: false, configurable: false })
58
59
  }
59
60
 
60
61
  tweak(this, undefined)
@@ -95,44 +96,60 @@ export function toFrozenSnapshot<T>(data: T): FrozenData<T> {
95
96
  }
96
97
  }
97
98
 
98
- function checkDataIsSerializableAndFreeze(data: any) {
99
- // TODO: detect cycles and throw if present?
100
-
101
- // primitives are ok
102
- if (isPrimitive(data)) {
103
- return
104
- }
105
-
106
- if (Array.isArray(data)) {
107
- const arrLen = data.length
108
- for (let i = 0; i < arrLen; i++) {
109
- const v = data[i]
110
- if (v === undefined && !getGlobalConfig().allowUndefinedArrayElements) {
111
- throw failure(
112
- "undefined is not supported inside arrays since it is not serializable in JSON, consider using null instead"
113
- )
114
- }
115
- checkDataIsSerializableAndFreeze(v)
99
+ function checkDataIsSerializableAndFreeze(data: unknown) {
100
+ // false means the object is still on the traversal stack; true means fully checked.
101
+ const checked = new Map<object, boolean>()
102
+ const stack: {
103
+ data: object
104
+ keys: string[] | undefined
105
+ length: number
106
+ nextIndex: number
107
+ }[] = []
108
+
109
+ const visit = (value: unknown) => {
110
+ if (isPrimitive(value)) return
111
+
112
+ if (!Array.isArray(value) && !isPlainObject(value)) {
113
+ throw failure(
114
+ `frozen data must be plainly serializable to JSON, but ${getSafeErrorValuePreview(value)} is not`
115
+ )
116
116
  }
117
- Object.freeze(data)
118
- return
117
+ const state = checked.get(value)
118
+ if (state === true) return
119
+ if (state === false) {
120
+ throw failure("frozen data must not contain cycles")
121
+ }
122
+ checked.set(value, false)
123
+ const keys = Array.isArray(value) ? undefined : Object.keys(value)
124
+ stack.push({
125
+ data: value,
126
+ keys,
127
+ length: keys ? keys.length : (value as unknown[]).length,
128
+ nextIndex: 0,
129
+ })
119
130
  }
120
131
 
121
- if (isPlainObject(data)) {
122
- const dataKeys = Object.keys(data)
123
- const dataKeysLen = dataKeys.length
124
- for (let i = 0; i < dataKeysLen; i++) {
125
- const k = dataKeys[i]
126
- const v = data[k]
132
+ visit(data)
133
+ while (stack.length > 0) {
134
+ const frame = stack[stack.length - 1]
135
+ if (frame.nextIndex === frame.length) {
136
+ Object.freeze(frame.data)
137
+ checked.set(frame.data, true)
138
+ stack.pop()
139
+ continue
140
+ }
127
141
 
128
- checkDataIsSerializableAndFreeze(k)
129
- checkDataIsSerializableAndFreeze(v)
142
+ const index = frame.nextIndex++
143
+ const value = frame.keys
144
+ ? (frame.data as Record<string, unknown>)[frame.keys[index]]
145
+ : (frame.data as unknown[])[index]
146
+ if (!frame.keys && value === undefined && !getGlobalConfig().allowUndefinedArrayElements) {
147
+ throw failure(
148
+ "undefined is not supported inside arrays since it is not serializable in JSON, consider using null instead"
149
+ )
130
150
  }
131
- Object.freeze(data)
132
- return
151
+ visit(value)
133
152
  }
134
-
135
- throw failure(`frozen data must be plainly serializable to JSON, but ${data} is not`)
136
153
  }
137
154
 
138
155
  /**
@@ -70,7 +70,7 @@ export abstract class BaseModel<
70
70
  */
71
71
  get [modelIdKey](): ModelIdPropertyType<TProps, ModelIdPropertyName> {
72
72
  const idProp = getModelIdPropertyName(this.constructor as any)
73
- return (idProp ? (this.$ as any)[idProp] : undefined) as ModelIdPropertyType<
73
+ return (idProp !== undefined ? (this.$ as any)[idProp] : undefined) as ModelIdPropertyType<
74
74
  TProps,
75
75
  ModelIdPropertyName
76
76
  >
@@ -78,7 +78,7 @@ export abstract class BaseModel<
78
78
 
79
79
  set [modelIdKey](newId: ModelIdPropertyType<TProps, ModelIdPropertyName>) {
80
80
  const idProp = getModelIdPropertyName(this.constructor as any)
81
- if (!idProp) {
81
+ if (idProp === undefined) {
82
82
  throw failure("$modelId cannot be set when there is no idProp set in the model")
83
83
  }
84
84
  ;(this.$ as any)[idProp] = newId
@@ -161,16 +161,11 @@ export abstract class BaseModel<
161
161
  }
162
162
 
163
163
  toString(options?: { withData?: boolean }) {
164
- const finalOptions = {
165
- withData: true,
166
- ...options,
167
- }
164
+ const { withData = true } = options ?? {}
168
165
 
169
166
  const firstPart = `${this.constructor.name}#${this[modelTypeKey]}`
170
167
 
171
- return finalOptions.withData
172
- ? `[${firstPart} ${JSON.stringify(getSnapshot(this))}]`
173
- : `[${firstPart}]`
168
+ return withData ? `[${firstPart} ${JSON.stringify(getSnapshot(this))}]` : `[${firstPart}]`
174
169
  }
175
170
  }
176
171
 
@@ -22,7 +22,8 @@ import type { Patch } from "../patch/Patch"
22
22
  import { updateModelInitialDataSnapshot } from "../snapshot/modelInitialData"
23
23
  import { tweakModel } from "../tweaker/tweakModel"
24
24
  import { tweakPlainObject } from "../tweaker/tweakPlainObject"
25
- import { failure, inDevMode, makePropReadonly } from "../utils"
25
+ import { typeCheckAfterCreation } from "../tweaker/typeChecking"
26
+ import { failure, hasOwnProp, inDevMode, makePropReadonly } from "../utils"
26
27
  import { setIfDifferent, setIfDifferentWithReturn } from "../utils/setIfDifferent"
27
28
  import type { AnyModel } from "./BaseModel"
28
29
  import { getModelIdPropertyName } from "./getModelMetadata"
@@ -49,13 +50,12 @@ export const internalNewModel = action(
49
50
  getModelDetails(modelClass)
50
51
 
51
52
  // use symbol if provided
52
- if (modelIdPropertyName && modelIdPropData) {
53
- let id: string | undefined
54
- if (initialData[modelIdPropertyName]) {
55
- id = initialData[modelIdPropertyName]
56
- } else {
57
- id = (modelIdPropData._defaultFn as () => string)()
58
- }
53
+ if (modelIdPropertyName !== undefined && modelIdPropData) {
54
+ const providedId = hasOwnProp(initialData, modelIdPropertyName)
55
+ ? initialData[modelIdPropertyName]
56
+ : undefined
57
+ const id: string =
58
+ providedId != null ? providedId : (modelIdPropData._defaultFn as () => string)()
59
59
  setIfDifferent(initialData, modelIdPropertyName, id)
60
60
  if (useInitialDataSnapshot) {
61
61
  updateModelInitialDataSnapshot(initialData, modelIdPropertyName, id)
@@ -77,7 +77,8 @@ export const internalNewModel = action(
77
77
 
78
78
  const propData = modelProps[k]
79
79
 
80
- const initialValue = initialData[k]
80
+ const hasInitialValue = hasOwnProp(initialData, k)
81
+ const initialValue = hasInitialValue ? initialData[k] : undefined
81
82
  let newValue = initialValue
82
83
  let changed = false
83
84
 
@@ -94,7 +95,7 @@ export const internalNewModel = action(
94
95
  if (defaultValue !== noDefaultValue) {
95
96
  changed = true
96
97
  newValue = defaultValue
97
- } else if (!(k in initialData)) {
98
+ } else if (!hasInitialValue) {
98
99
  // for mobx4, we need to set up properties even if they are undefined
99
100
  changed = true
100
101
  }
@@ -113,10 +114,7 @@ export const internalNewModel = action(
113
114
 
114
115
  // type check it if needed
115
116
  if (isModelAutoTypeCheckingEnabled()) {
116
- const err = modelObj.typeCheck()
117
- if (err) {
118
- err.throw()
119
- }
117
+ typeCheckAfterCreation(modelObj)
120
118
  }
121
119
  }
122
120
  )
@@ -142,10 +140,10 @@ export const internalFromSnapshotModel = action(
142
140
  let id: string | undefined
143
141
  let sn = snapshotInitialData.unprocessedSnapshot
144
142
 
145
- if (modelIdPropData && modelIdPropertyName) {
143
+ if (modelIdPropData && modelIdPropertyName !== undefined) {
146
144
  if (generateNewIds) {
147
145
  id = (modelIdPropData._defaultFn as () => string)()
148
- } else {
146
+ } else if (hasOwnProp(sn, modelIdPropertyName)) {
149
147
  id = sn[modelIdPropertyName]
150
148
  }
151
149
  }
@@ -168,7 +166,7 @@ export const internalFromSnapshotModel = action(
168
166
  let inversePatches: Patch[] | undefined
169
167
  let defaultsApplied: { key: string; oldValue: unknown; newValue: unknown }[] | undefined
170
168
 
171
- if (modelIdPropertyName) {
169
+ if (modelIdPropertyName !== undefined) {
172
170
  const initialValue = initialData[modelIdPropertyName]
173
171
  const valueChanged = setIfDifferentWithReturn(initialData, modelIdPropertyName, id)
174
172
 
@@ -198,7 +196,8 @@ export const internalFromSnapshotModel = action(
198
196
 
199
197
  const propData = modelProps[k]
200
198
 
201
- const initialValue = initialData[k]
199
+ const hasInitialValue = hasOwnProp(initialData, k)
200
+ const initialValue = hasInitialValue ? initialData[k] : undefined
202
201
  let newValue = initialValue
203
202
  let changed = false
204
203
 
@@ -208,7 +207,7 @@ export const internalFromSnapshotModel = action(
208
207
  if (defaultValue !== noDefaultValue) {
209
208
  changed = true
210
209
  newValue = defaultValue
211
- } else if (!(k in initialData!)) {
210
+ } else if (!hasInitialValue) {
212
211
  // for mobx4, we need to set up properties even if they are undefined
213
212
  changed = true
214
213
  }
@@ -292,10 +291,7 @@ export const internalFromSnapshotModel = action(
292
291
 
293
292
  // type check it if needed
294
293
  if (isModelAutoTypeCheckingEnabled()) {
295
- const err = modelObj.typeCheck()
296
- if (err) {
297
- err.throw()
298
- }
294
+ typeCheckAfterCreation(modelObj)
299
295
  }
300
296
  }
301
297
  )
@@ -310,7 +306,8 @@ function getModelDetails(modelClass: ModelClass<AnyModel>) {
310
306
 
311
307
  const modelIdPropertyName = getModelIdPropertyName(modelClass)
312
308
  const modelProps = getInternalModelClassPropsInfo(modelClass)
313
- const modelIdPropData = modelIdPropertyName ? modelProps[modelIdPropertyName] : undefined
309
+ const modelIdPropData =
310
+ modelIdPropertyName !== undefined ? modelProps[modelIdPropertyName] : undefined
314
311
 
315
312
  return { modelInfo, modelIdPropertyName, modelProps, modelIdPropData }
316
313
  }
@@ -175,7 +175,7 @@ export function getSnapshotModelTypeAndId(snapshot: unknown): ModelTypeAndId | u
175
175
  const modelIdPropertyName = getModelIdPropertyName(modelInfo.class as ModelClass<AnyModel>)
176
176
 
177
177
  let modelId: string | undefined
178
- if (modelIdPropertyName) {
178
+ if (modelIdPropertyName !== undefined) {
179
179
  const id = (snapshot as Record<string, unknown>)[modelIdPropertyName]
180
180
  modelId = typeof id === "string" ? id : undefined
181
181
  }
@@ -214,7 +214,7 @@ export function getModelTypeAndId(model: AnyModel | AnyDataModel): ModelTypeAndI
214
214
  const modelIdPropertyName = getModelIdPropertyName(modelInfo.class as ModelClass<AnyModel>)
215
215
 
216
216
  let modelId: string | undefined
217
- if (modelIdPropertyName) {
217
+ if (modelIdPropertyName !== undefined) {
218
218
  const id = (model as any)[modelIdKey] as string | undefined
219
219
  modelId = typeof id === "string" ? id : undefined
220
220
  }
@@ -1,5 +1,6 @@
1
1
  import type { AnyDataModel } from "../dataModel/BaseDataModel"
2
2
  import type { AnyModel } from "../model/BaseModel"
3
+ import { hasOwnProp } from "../utils"
3
4
  import type { ModelClass } from "./BaseModelShared"
4
5
 
5
6
  /**
@@ -20,8 +21,8 @@ export function addModelClassInitializer(
20
21
  init: ModelClassInitializer
21
22
  ) {
22
23
  let initializers: ModelClassInitializer[] = (modelClass as any)[modelInitializersSymbol]
23
- if (!initializers) {
24
- initializers = []
24
+ if (!hasOwnProp(modelClass, modelInitializersSymbol)) {
25
+ initializers = initializers ? initializers.slice() : []
25
26
  ;(modelClass as any)[modelInitializersSymbol] = initializers
26
27
  }
27
28
  initializers.push(init)
@@ -1,6 +1,7 @@
1
1
  import { HookAction } from "../action/hookActions"
2
2
  import { wrapModelMethodInActionIfNeeded } from "../action/wrapInAction"
3
3
  import type { AnyDataModel } from "../dataModel/BaseDataModel"
4
+ import { dataModelInstanceCache } from "../dataModel/dataModelInstanceCache"
4
5
  import { isDataModelClass } from "../dataModel/utils"
5
6
  import { enterInitPhase, exitInitPhase } from "../deepChange/onDeepChange"
6
7
  import { getGlobalConfig } from "../globalConfig"
@@ -41,22 +42,19 @@ export const model =
41
42
  ): MC =>
42
43
  internalModel(name, clazz) as any
43
44
 
45
+ interface AfterClassInitializationData {
46
+ needsMakeObservable: boolean | undefined
47
+ type: "class" | "data"
48
+ }
49
+
44
50
  const afterClassInitializationData = new WeakMap<
45
51
  ModelClass<AnyModel | AnyDataModel>,
46
- {
47
- needsMakeObservable: boolean | undefined
48
- type: "class" | "data"
49
- }
52
+ AfterClassInitializationData
50
53
  >()
51
54
 
52
- const runAfterClassInitialization = (
53
- target: ModelClass<AnyModel | AnyDataModel>,
54
- instance: any
55
- ) => {
55
+ const runAfterClassInitialization = (tag: AfterClassInitializationData, instance: any) => {
56
56
  runLateInitializationFunctions(instance, runAfterNewSymbol)
57
57
 
58
- const tag = afterClassInitializationData.get(target)!
59
-
60
58
  // compatibility with mobx 6 and 7
61
59
  if (tag.needsMakeObservable) {
62
60
  // we know it can be done and shouldn't fail
@@ -112,11 +110,33 @@ const runAfterClassInitialization = (
112
110
 
113
111
  const proxyClassHandler: ProxyHandler<ModelClass<AnyModel | AnyDataModel>> = {
114
112
  construct(clazz, args) {
115
- const instance = new (clazz as any)(...args)
113
+ const tag = afterClassInitializationData.get(clazz)!
116
114
 
117
- runAfterClassInitialization(clazz, instance)
115
+ if (tag.type !== "data") {
116
+ const instance = new (clazz as any)(...args)
117
+ runAfterClassInitialization(tag, instance)
118
+ return instance
119
+ }
120
+
121
+ // Data models are cached by their backing data, so constructing one over data that
122
+ // already has an instance must not rerun its class fields nor its lazy initializer.
123
+ const modelClass = args[1]?.modelClass ?? modelInfoByClass.get(clazz)!.class
124
+ const cached = dataModelInstanceCache.get(modelClass)?.get(args[0])
125
+ if (cached) {
126
+ return cached
127
+ }
118
128
 
119
- return instance
129
+ let instance: any
130
+ try {
131
+ instance = new (clazz as any)(...args)
132
+ runAfterClassInitialization(tag, instance)
133
+ return instance
134
+ } catch (error) {
135
+ // The base constructor cleans up after itself, but class fields and late
136
+ // initialization run after it returns, so discard those instances too.
137
+ dataModelInstanceCache.get(modelClass)?.delete(instance ? instance.$ : args[0])
138
+ throw error
139
+ }
120
140
  },
121
141
  }
122
142
 
@@ -245,7 +265,7 @@ export function decoratedModel<M, MC extends abstract new (...ags: any) => M>(
245
265
  })
246
266
  }
247
267
 
248
- return (name ? model(name)(clazz as unknown as ModelClass<AnyModel>) : clazz) as MC
268
+ return (name !== undefined ? model(name)(clazz as unknown as ModelClass<AnyModel>) : clazz) as MC
249
269
  }
250
270
 
251
271
  type MobxAnnotation = {