mobx-keystone 1.24.1 → 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 (122) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +5 -1
  3. package/dist/mobx-keystone.esm.js +1608 -1072
  4. package/dist/mobx-keystone.esm.mjs +1608 -1072
  5. package/dist/mobx-keystone.umd.js +1613 -1072
  6. package/dist/types/actionMiddlewares/actionSerialization/dateSerializer.d.ts +1 -1
  7. package/dist/types/dataModel/dataModelInstanceCache.d.ts +1 -0
  8. package/dist/types/deepChange/onDeepChange.d.ts +9 -0
  9. package/dist/types/parent/detach.d.ts +1 -1
  10. package/dist/types/parent/findChildren.d.ts +1 -1
  11. package/dist/types/patch/applyPatches.d.ts +2 -0
  12. package/dist/types/redux/connectReduxDevTools.d.ts +2 -1
  13. package/dist/types/snapshot/SnapshotOf.d.ts +2 -2
  14. package/dist/types/standardActions/arrayActions.d.ts +1 -1
  15. package/dist/types/treeUtils/sandbox.d.ts +0 -4
  16. package/dist/types/types/TypeCheckError.d.ts +1 -0
  17. package/dist/types/types/utility/typesRefinement.d.ts +2 -2
  18. package/dist/types/utils/forEachWithDelayedThrow.d.ts +1 -0
  19. package/dist/types/utils/index.d.ts +13 -0
  20. package/dist/types/utils/mapUtils.d.ts +2 -2
  21. package/dist/types/utils/setLike.d.ts +1 -0
  22. package/package.json +7 -7
  23. package/src/action/applyAction.ts +1 -1
  24. package/src/action/middleware.ts +17 -36
  25. package/src/action/modelFlow.ts +4 -5
  26. package/src/action/mutationBatch.ts +10 -10
  27. package/src/action/pendingActions.ts +3 -4
  28. package/src/actionMiddlewares/actionSerialization/actionSerialization.ts +19 -14
  29. package/src/actionMiddlewares/actionSerialization/applySerializedAction.ts +29 -20
  30. package/src/actionMiddlewares/actionSerialization/arraySerializer.ts +1 -1
  31. package/src/actionMiddlewares/actionSerialization/dateSerializer.ts +4 -3
  32. package/src/actionMiddlewares/actionSerialization/mapSerializer.ts +4 -12
  33. package/src/actionMiddlewares/actionSerialization/setSerializer.ts +4 -13
  34. package/src/actionMiddlewares/actionTrackingMiddleware.ts +11 -13
  35. package/src/actionMiddlewares/transactionMiddleware.ts +42 -11
  36. package/src/actionMiddlewares/undoMiddleware.ts +76 -60
  37. package/src/actionMiddlewares/utils.ts +2 -1
  38. package/src/computedTree/computedTree.ts +14 -3
  39. package/src/context/context.ts +17 -45
  40. package/src/dataModel/BaseDataModel.ts +20 -16
  41. package/src/dataModel/dataModelInstanceCache.ts +8 -0
  42. package/src/deepChange/onDeepChange.ts +179 -24
  43. package/src/frozen/Frozen.ts +50 -33
  44. package/src/model/BaseModel.ts +15 -14
  45. package/src/model/newModel.ts +32 -28
  46. package/src/model/utils.ts +2 -2
  47. package/src/modelShared/modelClassInitializer.ts +3 -2
  48. package/src/modelShared/modelDecorator.ts +34 -14
  49. package/src/modelShared/modelInfo.ts +1 -1
  50. package/src/modelShared/prop.ts +6 -9
  51. package/src/modelShared/sharedInternalModel.ts +16 -3
  52. package/src/parent/detach.ts +1 -1
  53. package/src/parent/findChildren.ts +4 -7
  54. package/src/parent/findParent.ts +14 -9
  55. package/src/parent/onChildAttachedTo.ts +45 -40
  56. package/src/parent/path.ts +11 -7
  57. package/src/parent/setParent.ts +33 -14
  58. package/src/parent/walkTree.ts +21 -37
  59. package/src/patch/applyPatches.ts +75 -60
  60. package/src/patch/emitPatch.ts +142 -26
  61. package/src/patch/jsonPatch.ts +5 -0
  62. package/src/patch/patchRecorder.ts +18 -14
  63. package/src/redux/connectReduxDevTools.ts +24 -18
  64. package/src/redux/redux.ts +5 -21
  65. package/src/ref/core.ts +18 -2
  66. package/src/rootStore/attachDetach.ts +27 -16
  67. package/src/rootStore/rootStore.ts +10 -6
  68. package/src/snapshot/SnapshotOf.ts +2 -2
  69. package/src/snapshot/applySnapshot.ts +10 -1
  70. package/src/snapshot/clone.ts +1 -2
  71. package/src/snapshot/cloneTreeValue.ts +1 -2
  72. package/src/snapshot/fromModelSnapshot.ts +6 -2
  73. package/src/snapshot/fromSnapshot.ts +3 -1
  74. package/src/snapshot/internal.ts +22 -9
  75. package/src/snapshot/modelInitialData.ts +14 -19
  76. package/src/snapshot/onSnapshot.ts +12 -1
  77. package/src/snapshot/reconcileModelSnapshot.ts +16 -8
  78. package/src/snapshot/reconcilePlainObjectSnapshot.ts +5 -4
  79. package/src/snapshot/reconcileSnapshot.ts +13 -4
  80. package/src/standardActions/arrayActions.ts +12 -2
  81. package/src/standardActions/objectActions.ts +3 -2
  82. package/src/transforms/ImmutableDate.ts +4 -0
  83. package/src/transforms/asMap.ts +3 -1
  84. package/src/treeUtils/deepEquals.ts +15 -13
  85. package/src/treeUtils/draft.ts +8 -0
  86. package/src/treeUtils/sandbox.ts +31 -27
  87. package/src/tweaker/core.ts +2 -4
  88. package/src/tweaker/tweak.ts +8 -8
  89. package/src/tweaker/tweakArray.ts +86 -37
  90. package/src/tweaker/tweakFrozen.ts +0 -1
  91. package/src/tweaker/tweakModel.ts +0 -1
  92. package/src/tweaker/tweakPlainObject.ts +78 -29
  93. package/src/tweaker/typeChecking.ts +132 -33
  94. package/src/types/TypeCheckError.ts +17 -19
  95. package/src/types/createCachedTypeCheck.ts +27 -2
  96. package/src/types/objectBased/typesDataModelData.ts +4 -2
  97. package/src/types/objectBased/typesModel.ts +4 -2
  98. package/src/types/objectBased/typesObject.ts +20 -16
  99. package/src/types/objectBased/typesRecord.ts +6 -13
  100. package/src/types/objectBased/typesRef.ts +2 -2
  101. package/src/types/primitiveBased/typesEnum.ts +4 -2
  102. package/src/types/primitiveBased/typesPrimitive.ts +9 -4
  103. package/src/types/typeCheckErrorUtils.ts +1 -0
  104. package/src/types/utility/typesCodecSupport.ts +97 -56
  105. package/src/types/utility/typesMap.ts +24 -41
  106. package/src/types/utility/typesRefinement.ts +3 -2
  107. package/src/types/utility/typesSet.ts +24 -0
  108. package/src/utils/ModelPool.ts +3 -3
  109. package/src/utils/chainFns.ts +4 -0
  110. package/src/utils/decorators.ts +1 -1
  111. package/src/utils/errorDiagnostics.ts +6 -2
  112. package/src/utils/forEachWithDelayedThrow.ts +83 -0
  113. package/src/utils/index.ts +34 -2
  114. package/src/utils/mapUtils.ts +3 -7
  115. package/src/utils/setIfDifferent.ts +3 -2
  116. package/src/utils/setLike.ts +134 -0
  117. package/src/wrappers/ArraySet.ts +5 -9
  118. package/src/wrappers/ObjectMap.ts +15 -11
  119. package/src/wrappers/asMap.ts +30 -18
  120. package/src/wrappers/asSet.ts +36 -12
  121. package/dist/types/action/modelFlowPromiseGenerator.d.ts +0 -1
  122. 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
  /**
@@ -12,6 +12,7 @@ import type {
12
12
  ModelPropsToUntransformedData,
13
13
  } from "../modelShared/prop"
14
14
  import { getSnapshot } from "../snapshot/getSnapshot"
15
+ import { setModelInitialDataSnapshot } from "../snapshot/modelInitialData"
15
16
  import type { SnapshotInOfModel, SnapshotOutOfModel } from "../snapshot/SnapshotOf"
16
17
  import { typesModel } from "../types/objectBased/typesModel"
17
18
  import type { TypeCheckError } from "../types/TypeCheckError"
@@ -69,7 +70,7 @@ export abstract class BaseModel<
69
70
  */
70
71
  get [modelIdKey](): ModelIdPropertyType<TProps, ModelIdPropertyName> {
71
72
  const idProp = getModelIdPropertyName(this.constructor as any)
72
- return (idProp ? (this.$ as any)[idProp] : undefined) as ModelIdPropertyType<
73
+ return (idProp !== undefined ? (this.$ as any)[idProp] : undefined) as ModelIdPropertyType<
73
74
  TProps,
74
75
  ModelIdPropertyName
75
76
  >
@@ -77,7 +78,7 @@ export abstract class BaseModel<
77
78
 
78
79
  set [modelIdKey](newId: ModelIdPropertyType<TProps, ModelIdPropertyName>) {
79
80
  const idProp = getModelIdPropertyName(this.constructor as any)
80
- if (!idProp) {
81
+ if (idProp === undefined) {
81
82
  throw failure("$modelId cannot be set when there is no idProp set in the model")
82
83
  }
83
84
  ;(this.$ as any)[idProp] = newId
@@ -146,25 +147,25 @@ export abstract class BaseModel<
146
147
  // plain new
147
148
  assertIsObject(initialData, "initialData")
148
149
 
149
- internalNewModel(
150
- this,
151
- observable.object(initialData as any, undefined, { deep: false }),
152
- modelClass!
153
- )
150
+ // Empty input is filled by the constructor below. Track its primitive
151
+ // defaults directly instead of rereading observable fields for the snapshot.
152
+ const useInitialDataSnapshot = Reflect.ownKeys(initialData).length === 0
153
+ const observableInitialData = observable.object(initialData as any, undefined, {
154
+ deep: false,
155
+ })
156
+ if (useInitialDataSnapshot) {
157
+ setModelInitialDataSnapshot(observableInitialData, {})
158
+ }
159
+ internalNewModel(this, observableInitialData, modelClass!, useInitialDataSnapshot)
154
160
  }
155
161
  }
156
162
 
157
163
  toString(options?: { withData?: boolean }) {
158
- const finalOptions = {
159
- withData: true,
160
- ...options,
161
- }
164
+ const { withData = true } = options ?? {}
162
165
 
163
166
  const firstPart = `${this.constructor.name}#${this[modelTypeKey]}`
164
167
 
165
- return finalOptions.withData
166
- ? `[${firstPart} ${JSON.stringify(getSnapshot(this))}]`
167
- : `[${firstPart}]`
168
+ return withData ? `[${firstPart} ${JSON.stringify(getSnapshot(this))}]` : `[${firstPart}]`
168
169
  }
169
170
  }
170
171
 
@@ -19,10 +19,11 @@ import {
19
19
  hasGlobalPatchListeners,
20
20
  } from "../patch/emitPatch"
21
21
  import type { Patch } from "../patch/Patch"
22
- import { invalidateModelInitialDataSnapshot } from "../snapshot/modelInitialData"
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"
@@ -38,7 +39,8 @@ export const internalNewModel = action(
38
39
  <M extends AnyModel>(
39
40
  origModelObj: M,
40
41
  initialData: ModelCreationData<M>,
41
- modelClass: ModelClass<AnyModel>
42
+ modelClass: ModelClass<AnyModel>,
43
+ useInitialDataSnapshot: boolean
42
44
  ): void => {
43
45
  if (inDevMode) {
44
46
  assertIsModelClass(modelClass, "modelClass")
@@ -48,14 +50,16 @@ export const internalNewModel = action(
48
50
  getModelDetails(modelClass)
49
51
 
50
52
  // use symbol if provided
51
- if (modelIdPropertyName && modelIdPropData) {
52
- let id: string | undefined
53
- if (initialData[modelIdPropertyName]) {
54
- id = initialData[modelIdPropertyName]
55
- } else {
56
- id = (modelIdPropData._defaultFn as () => string)()
57
- }
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)()
58
59
  setIfDifferent(initialData, modelIdPropertyName, id)
60
+ if (useInitialDataSnapshot) {
61
+ updateModelInitialDataSnapshot(initialData, modelIdPropertyName, id)
62
+ }
59
63
  }
60
64
 
61
65
  const modelObj = origModelObj as O.Writable<M>
@@ -73,7 +77,8 @@ export const internalNewModel = action(
73
77
 
74
78
  const propData = modelProps[k]
75
79
 
76
- const initialValue = initialData[k]
80
+ const hasInitialValue = hasOwnProp(initialData, k)
81
+ const initialValue = hasInitialValue ? initialData[k] : undefined
77
82
  let newValue = initialValue
78
83
  let changed = false
79
84
 
@@ -90,7 +95,7 @@ export const internalNewModel = action(
90
95
  if (defaultValue !== noDefaultValue) {
91
96
  changed = true
92
97
  newValue = defaultValue
93
- } else if (!(k in initialData)) {
98
+ } else if (!hasInitialValue) {
94
99
  // for mobx4, we need to set up properties even if they are undefined
95
100
  changed = true
96
101
  }
@@ -99,6 +104,9 @@ export const internalNewModel = action(
99
104
  if (changed) {
100
105
  // setIfDifferent not required
101
106
  set(initialData, k, newValue)
107
+ if (useInitialDataSnapshot) {
108
+ updateModelInitialDataSnapshot(initialData, k, newValue)
109
+ }
102
110
  }
103
111
  }
104
112
 
@@ -106,10 +114,7 @@ export const internalNewModel = action(
106
114
 
107
115
  // type check it if needed
108
116
  if (isModelAutoTypeCheckingEnabled()) {
109
- const err = modelObj.typeCheck()
110
- if (err) {
111
- err.throw()
112
- }
117
+ typeCheckAfterCreation(modelObj)
113
118
  }
114
119
  }
115
120
  )
@@ -135,10 +140,10 @@ export const internalFromSnapshotModel = action(
135
140
  let id: string | undefined
136
141
  let sn = snapshotInitialData.unprocessedSnapshot
137
142
 
138
- if (modelIdPropData && modelIdPropertyName) {
143
+ if (modelIdPropData && modelIdPropertyName !== undefined) {
139
144
  if (generateNewIds) {
140
145
  id = (modelIdPropData._defaultFn as () => string)()
141
- } else {
146
+ } else if (hasOwnProp(sn, modelIdPropertyName)) {
142
147
  id = sn[modelIdPropertyName]
143
148
  }
144
149
  }
@@ -161,12 +166,12 @@ export const internalFromSnapshotModel = action(
161
166
  let inversePatches: Patch[] | undefined
162
167
  let defaultsApplied: { key: string; oldValue: unknown; newValue: unknown }[] | undefined
163
168
 
164
- if (modelIdPropertyName) {
169
+ if (modelIdPropertyName !== undefined) {
165
170
  const initialValue = initialData[modelIdPropertyName]
166
171
  const valueChanged = setIfDifferentWithReturn(initialData, modelIdPropertyName, id)
167
172
 
168
173
  if (valueChanged) {
169
- invalidateModelInitialDataSnapshot(initialData)
174
+ updateModelInitialDataSnapshot(initialData, modelIdPropertyName, id)
170
175
  }
171
176
 
172
177
  if (valueChanged && shouldEmitPatches) {
@@ -191,7 +196,8 @@ export const internalFromSnapshotModel = action(
191
196
 
192
197
  const propData = modelProps[k]
193
198
 
194
- const initialValue = initialData[k]
199
+ const hasInitialValue = hasOwnProp(initialData, k)
200
+ const initialValue = hasInitialValue ? initialData[k] : undefined
195
201
  let newValue = initialValue
196
202
  let changed = false
197
203
 
@@ -201,7 +207,7 @@ export const internalFromSnapshotModel = action(
201
207
  if (defaultValue !== noDefaultValue) {
202
208
  changed = true
203
209
  newValue = defaultValue
204
- } else if (!(k in initialData!)) {
210
+ } else if (!hasInitialValue) {
205
211
  // for mobx4, we need to set up properties even if they are undefined
206
212
  changed = true
207
213
  }
@@ -210,7 +216,7 @@ export const internalFromSnapshotModel = action(
210
216
  if (changed) {
211
217
  // setIfDifferent not required
212
218
  set(initialData, k, newValue)
213
- invalidateModelInitialDataSnapshot(initialData)
219
+ updateModelInitialDataSnapshot(initialData, k, newValue)
214
220
 
215
221
  if (newValue !== initialValue) {
216
222
  if (shouldEmitPatches) {
@@ -285,10 +291,7 @@ export const internalFromSnapshotModel = action(
285
291
 
286
292
  // type check it if needed
287
293
  if (isModelAutoTypeCheckingEnabled()) {
288
- const err = modelObj.typeCheck()
289
- if (err) {
290
- err.throw()
291
- }
294
+ typeCheckAfterCreation(modelObj)
292
295
  }
293
296
  }
294
297
  )
@@ -303,7 +306,8 @@ function getModelDetails(modelClass: ModelClass<AnyModel>) {
303
306
 
304
307
  const modelIdPropertyName = getModelIdPropertyName(modelClass)
305
308
  const modelProps = getInternalModelClassPropsInfo(modelClass)
306
- const modelIdPropData = modelIdPropertyName ? modelProps[modelIdPropertyName] : undefined
309
+ const modelIdPropData =
310
+ modelIdPropertyName !== undefined ? modelProps[modelIdPropertyName] : undefined
307
311
 
308
312
  return { modelInfo, modelIdPropertyName, modelProps, modelIdPropData }
309
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)