mobx-keystone 1.22.0 → 1.23.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 (68) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/mobx-keystone.esm.js +6702 -5797
  3. package/dist/mobx-keystone.esm.mjs +6702 -5797
  4. package/dist/mobx-keystone.umd.js +6718 -5798
  5. package/dist/types/action/runUnprotected.d.ts +8 -4
  6. package/dist/types/model/Model.d.ts +5 -0
  7. package/dist/types/modelShared/prop.d.ts +8 -4
  8. package/dist/types/snapshot/modelInitialData.d.ts +1 -0
  9. package/dist/types/tweaker/treeNodeMetadata.d.ts +1 -0
  10. package/dist/types/types/createCachedTypeCheck.d.ts +1 -0
  11. package/package.json +11 -10
  12. package/src/action/middleware.ts +5 -18
  13. package/src/action/mutationBatch.ts +72 -0
  14. package/src/action/runUnprotected.ts +21 -8
  15. package/src/action/wrapInAction.ts +45 -15
  16. package/src/dataModel/BaseDataModel.ts +4 -8
  17. package/src/deepChange/onDeepChange.ts +246 -16
  18. package/src/model/BaseModel.ts +4 -12
  19. package/src/model/Model.ts +5 -0
  20. package/src/model/newModel.ts +68 -36
  21. package/src/modelShared/prop.ts +105 -63
  22. package/src/modelShared/sharedInternalModel.ts +91 -60
  23. package/src/parent/core.ts +16 -10
  24. package/src/parent/coreObjectChildren.ts +219 -21
  25. package/src/parent/path.ts +6 -5
  26. package/src/parent/setParent.ts +5 -5
  27. package/src/patch/emitPatch.ts +102 -20
  28. package/src/snapshot/fromArraySnapshot.ts +11 -2
  29. package/src/snapshot/fromModelSnapshot.ts +10 -3
  30. package/src/snapshot/fromPlainObjectSnapshot.ts +18 -7
  31. package/src/snapshot/fromSnapshot.ts +31 -9
  32. package/src/snapshot/getSnapshot.ts +8 -3
  33. package/src/snapshot/internal.ts +243 -82
  34. package/src/snapshot/modelInitialData.ts +41 -0
  35. package/src/snapshot/reconcileArraySnapshot.ts +9 -3
  36. package/src/snapshot/reconcileModelSnapshot.ts +97 -17
  37. package/src/snapshot/reconcilePlainObjectSnapshot.ts +9 -3
  38. package/src/tweaker/core.ts +30 -5
  39. package/src/tweaker/treeNodeMetadata.ts +38 -0
  40. package/src/tweaker/tweak.ts +15 -4
  41. package/src/tweaker/tweakArray.ts +159 -121
  42. package/src/tweaker/tweakFrozen.ts +2 -2
  43. package/src/tweaker/tweakModel.ts +2 -2
  44. package/src/tweaker/tweakPlainObject.ts +114 -122
  45. package/src/tweaker/typeChecking.ts +10 -1
  46. package/src/types/TypeChecker.ts +261 -15
  47. package/src/types/arrayBased/typesArray.ts +38 -40
  48. package/src/types/arrayBased/typesTuple.ts +51 -34
  49. package/src/types/createCachedTypeCheck.ts +181 -0
  50. package/src/types/objectBased/typesArraySet.ts +42 -32
  51. package/src/types/objectBased/typesDataModelData.ts +18 -11
  52. package/src/types/objectBased/typesModel.ts +1 -1
  53. package/src/types/objectBased/typesObject.ts +57 -28
  54. package/src/types/objectBased/typesObjectMap.ts +51 -40
  55. package/src/types/objectBased/typesRecord.ts +59 -39
  56. package/src/types/objectBased/typesRef.ts +1 -1
  57. package/src/types/primitiveBased/typesPrimitive.ts +9 -9
  58. package/src/types/tProp.ts +80 -44
  59. package/src/types/utility/typesOr.ts +35 -24
  60. package/src/types/utility/typesRefinement.ts +21 -8
  61. package/src/types/utility/typesSkipCheck.ts +21 -8
  62. package/src/types/utility/typesTag.ts +21 -9
  63. package/src/types/utility/typesUnchecked.ts +2 -3
  64. package/src/utils/ModelPool.ts +34 -19
  65. package/src/utils/errorDiagnostics.ts +60 -18
  66. package/src/utils/index.ts +9 -7
  67. package/src/types/createPerEntryCachedCheck.ts +0 -166
  68. /package/dist/types/{types/createPerEntryCachedCheck.d.ts → action/mutationBatch.d.ts} +0 -0
@@ -2,7 +2,7 @@ import { remove } from "mobx"
2
2
  import type { AnyModel } from "../model/BaseModel"
3
3
  import { getModelIdPropertyName } from "../model/getModelMetadata"
4
4
  import { isReservedModelKey, modelIdKey, modelTypeKey } from "../model/metadata"
5
- import { getModelOrSnapshotTypeAndId, getSnapshotModelType, isModel } from "../model/utils"
5
+ import { getSnapshotModelType, isModel } from "../model/utils"
6
6
  import type { ModelClass } from "../modelShared/BaseModelShared"
7
7
  import { getModelInfoForName, getModelNotRegisteredErrorMessage } from "../modelShared/modelInfo"
8
8
  import { getInternalModelClassPropsInfo } from "../modelShared/modelPropsInfo"
@@ -12,7 +12,10 @@ import {
12
12
  noDefaultValue,
13
13
  } from "../modelShared/prop"
14
14
  import { deepEquals } from "../treeUtils/deepEquals"
15
- import { runTypeCheckingAfterChange } from "../tweaker/typeChecking"
15
+ import {
16
+ isTypeCheckingAfterChangeEnabled,
17
+ runTypeCheckingAfterChange,
18
+ } from "../tweaker/typeChecking"
16
19
  import { withoutTypeChecking } from "../tweaker/withoutTypeChecking"
17
20
  import { isArray } from "../utils"
18
21
  import { withErrorModelTrailEntry, withErrorPathSegment } from "../utils/errorDiagnostics"
@@ -20,6 +23,7 @@ import type { ModelPool } from "../utils/ModelPool"
20
23
  import { setIfDifferent } from "../utils/setIfDifferent"
21
24
  import { fromSnapshot } from "./fromSnapshot"
22
25
  import { getSnapshot } from "./getSnapshot"
26
+ import { flushInternalSnapshot, getInternalSnapshot } from "./internal"
23
27
  import { detachIfNeeded, reconcileSnapshot, registerReconciler } from "./reconcileSnapshot"
24
28
  import type { SnapshotInOfModel } from "./SnapshotOf"
25
29
  import { SnapshotProcessingError } from "./SnapshotProcessingError"
@@ -42,15 +46,45 @@ function reconcileModelSnapshot(
42
46
  }
43
47
 
44
48
  const modelClass = modelInfo.class as ModelClass<AnyModel>
45
- const trailModelId = getModelOrSnapshotTypeAndId(sn)?.modelId
46
49
 
47
- return withErrorModelTrailEntry(type, trailModelId, () => {
48
- // try to use model from pool if possible
49
- const modelInPool = modelPool.findModelForSnapshot(sn)
50
+ // Snapshot processors are pure, deterministic, and required to round-trip
51
+ // the canonical output snapshot. A successful canonical no-op therefore
52
+ // cannot produce a diagnostic, so keep this hottest path outside the
53
+ // model-trail bookkeeping and defer id metadata until needed. The
54
+ // positional value was already flushed by the getSnapshot call in
55
+ // reconcileSnapshot, so its transformed snapshot is current.
56
+ if (isCanonicalSnapshotNoOp(value, type, sn)) {
57
+ return value
58
+ }
59
+
60
+ const modelIdPropertyName = getModelIdPropertyName(modelClass)
61
+ const incomingModelId = modelIdPropertyName ? sn[modelIdPropertyName] : undefined
62
+ const positionalModelMatches =
63
+ isModel(value) &&
64
+ value[modelTypeKey] === type &&
65
+ (!modelIdPropertyName || value[modelIdKey] === incomingModelId)
66
+
67
+ // The positional model is already the desired instance in the common
68
+ // stable-update case. Only moved identified models need a pool lookup.
69
+ if (!positionalModelMatches && modelIdPropertyName) {
70
+ const modelInPool = modelPool.findModelByTypeAndId(type, incomingModelId)
50
71
  if (modelInPool) {
51
72
  value = modelInPool
73
+
74
+ // A pool model may carry snapshot updates still pending propagation from
75
+ // earlier mutations in the same action, so flush before comparing.
76
+ flushInternalSnapshot(modelInPool, false)
77
+
78
+ // Repeat the canonical comparison so moved models can also avoid
79
+ // processor and property-by-property reconciliation.
80
+ if (isCanonicalSnapshotNoOp(modelInPool, type, sn)) {
81
+ return modelInPool
82
+ }
52
83
  }
84
+ }
53
85
 
86
+ const trailModelId = typeof incomingModelId === "string" ? incomingModelId : undefined
87
+ return withErrorModelTrailEntry(type, trailModelId, () => {
54
88
  // we don't check by actual instance since the class might be a different one due to hot reloading
55
89
  if (!isModel(value) || value[modelTypeKey] !== type) {
56
90
  // different kind of model type, no reconciliation possible
@@ -58,12 +92,8 @@ function reconcileModelSnapshot(
58
92
  }
59
93
 
60
94
  const modelProps = getInternalModelClassPropsInfo(modelClass)
61
- const modelIdPropertyName = getModelIdPropertyName(modelClass)
62
-
63
95
  if (modelIdPropertyName) {
64
- const id = sn[modelIdPropertyName]
65
-
66
- if (value[modelIdKey] !== id) {
96
+ if (value[modelIdKey] !== incomingModelId) {
67
97
  // different id, no reconciliation possible
68
98
  return fromSnapshot<AnyModel>(sn)
69
99
  }
@@ -76,13 +106,26 @@ function reconcileModelSnapshot(
76
106
  }
77
107
 
78
108
  const modelObj: AnyModel = value
79
- const snapshotBeforeChanges = getSnapshot(modelObj)
109
+ const typeCheckingAfterChangeEnabled = isTypeCheckingAfterChangeEnabled()
110
+ const actualModelClass = (modelObj as any).constructor as ModelClass<AnyModel>
111
+ const fromSnapshotProcessor = actualModelClass.fromSnapshotProcessor
112
+ const snapshotBeforeChanges = typeCheckingAfterChangeEnabled ? getSnapshot(modelObj) : undefined
80
113
 
81
114
  withoutTypeChecking(() => {
82
- const modelClass: ModelClass<AnyModel> = (modelObj as any).constructor
83
- const processedSn = modelClass.fromSnapshotProcessor
84
- ? modelClass.fromSnapshotProcessor(sn)
85
- : sn
115
+ const processedSn = fromSnapshotProcessor ? fromSnapshotProcessor(sn) : sn
116
+
117
+ // Non-canonical inputs still run the input processor. Once converted to
118
+ // the model's stored shape, avoid reconciliation when that shape is
119
+ // already current. Comparing against the untransformed snapshot is
120
+ // essential because an output processor may have changed its shape.
121
+ if (fromSnapshotProcessor) {
122
+ if (!snapshotBeforeChanges) {
123
+ flushInternalSnapshot(modelObj, false)
124
+ }
125
+ if (snapshotSlotsAreEqual(getInternalSnapshot(modelObj)!.untransformed, processedSn)) {
126
+ return
127
+ }
128
+ }
86
129
 
87
130
  const data = modelObj.$
88
131
 
@@ -136,12 +179,49 @@ function reconcileModelSnapshot(
136
179
  }
137
180
  })
138
181
 
139
- runTypeCheckingAfterChange(modelObj, undefined, snapshotBeforeChanges)
182
+ if (typeCheckingAfterChangeEnabled) {
183
+ runTypeCheckingAfterChange(modelObj, undefined, snapshotBeforeChanges)
184
+ }
140
185
 
141
186
  return modelObj
142
187
  })
143
188
  }
144
189
 
190
+ function isCanonicalSnapshotNoOp(value: any, type: string, sn: any): boolean {
191
+ return (
192
+ isModel(value) &&
193
+ value[modelTypeKey] === type &&
194
+ snapshotSlotsAreEqual(getInternalSnapshot(value)!.transformed, sn)
195
+ )
196
+ }
197
+
198
+ function snapshotSlotsAreEqual(
199
+ currentSnapshot: Record<string, unknown>,
200
+ incomingSnapshot: any
201
+ ): boolean {
202
+ const currentKeys = Object.keys(currentSnapshot)
203
+ const incomingKeys = Object.keys(incomingSnapshot)
204
+ if (currentKeys.length !== incomingKeys.length) {
205
+ return false
206
+ }
207
+
208
+ for (let i = 0; i < currentKeys.length; i++) {
209
+ const key = currentKeys[i]
210
+ // a matching key at the same index proves the key is an own property of
211
+ // the incoming snapshot without an Object.hasOwn call; canonical snapshots
212
+ // keep key order, so the fallback is rare (never trust inherited values,
213
+ // e.g. a crafted __proto__ chain, without an own-property proof)
214
+ if (incomingKeys[i] !== key && !Object.hasOwn(incomingSnapshot, key)) {
215
+ return false
216
+ }
217
+ if (currentSnapshot[key] !== incomingSnapshot[key]) {
218
+ return false
219
+ }
220
+ }
221
+
222
+ return true
223
+ }
224
+
145
225
  /**
146
226
  * @internal
147
227
  */
@@ -1,5 +1,8 @@
1
1
  import { isObservableObject, remove } from "mobx"
2
- import { runTypeCheckingAfterChange } from "../tweaker/typeChecking"
2
+ import {
3
+ isTypeCheckingAfterChangeEnabled,
4
+ runTypeCheckingAfterChange,
5
+ } from "../tweaker/typeChecking"
3
6
  import { withoutTypeChecking } from "../tweaker/withoutTypeChecking"
4
7
  import { isPlainObject } from "../utils"
5
8
  import { withErrorPathSegment } from "../utils/errorDiagnostics"
@@ -23,7 +26,8 @@ function reconcilePlainObjectSnapshot(
23
26
  }
24
27
 
25
28
  const plainObj = value
26
- const snapshotBeforeChanges = getSnapshot(plainObj)
29
+ const typeCheckingAfterChangeEnabled = isTypeCheckingAfterChangeEnabled()
30
+ const snapshotBeforeChanges = typeCheckingAfterChangeEnabled ? getSnapshot(plainObj) : undefined
27
31
 
28
32
  withoutTypeChecking(() => {
29
33
  // remove excess props
@@ -54,7 +58,9 @@ function reconcilePlainObjectSnapshot(
54
58
  }
55
59
  })
56
60
 
57
- runTypeCheckingAfterChange(plainObj, undefined, snapshotBeforeChanges)
61
+ if (typeCheckingAfterChangeEnabled) {
62
+ runTypeCheckingAfterChange(plainObj, undefined, snapshotBeforeChanges)
63
+ }
58
64
 
59
65
  return plainObj
60
66
  }
@@ -1,20 +1,45 @@
1
1
  import { runInAction } from "mobx"
2
- import { dataObjectParent } from "../parent/core"
2
+ import { hasDataObjectParent } from "../parent/core"
3
3
  import { failure, isPrimitive } from "../utils"
4
+ import { treeNodeMetadata } from "./treeNodeMetadata"
5
+
6
+ type Untweaker = (() => void) & {
7
+ [secondaryUntweakerSymbol]?: () => void
8
+ }
9
+
10
+ const secondaryUntweakerSymbol = Symbol("secondaryUntweaker")
4
11
 
5
12
  /**
13
+ * Stores two existing cleanup functions without allocating a third function to
14
+ * close over them. The primary function is internal and never exposed.
15
+ *
6
16
  * @internal
7
17
  */
8
- export const tweakedObjects = new WeakMap<object, undefined | (() => void)>()
18
+ export function setTweakedObjectUntweakers(
19
+ value: object,
20
+ primaryUntweaker: Untweaker,
21
+ secondaryUntweaker: () => void
22
+ ): void {
23
+ primaryUntweaker[secondaryUntweakerSymbol] = secondaryUntweaker
24
+ treeNodeMetadata.get(value)!.untweaker = primaryUntweaker
25
+ }
9
26
 
10
27
  /**
11
28
  * @internal
12
29
  */
13
30
  export function isTweakedObject(value: unknown, canBeDataObject: boolean): value is object {
14
- if (!canBeDataObject && dataObjectParent.has(value as object)) {
31
+ if (!canBeDataObject && hasDataObjectParent(value as object)) {
15
32
  return false
16
33
  }
17
- return tweakedObjects.has(value as object)
34
+ return treeNodeMetadata.get(value as object)?.tweaked === true
35
+ }
36
+
37
+ /**
38
+ * @internal
39
+ */
40
+ export function runTweakedObjectUntweakers(untweaker: Untweaker): void {
41
+ untweaker()
42
+ untweaker[secondaryUntweakerSymbol]?.()
18
43
  }
19
44
 
20
45
  /**
@@ -35,7 +60,7 @@ export function assertTweakedObject(
35
60
  argName: string,
36
61
  canBeDataObject = false
37
62
  ): asserts treeNode is object {
38
- if (!canBeDataObject && dataObjectParent.has(treeNode as object)) {
63
+ if (!canBeDataObject && hasDataObjectParent(treeNode as object)) {
39
64
  throw failure(`${argName} must be the model object instance instead of the '$' sub-object`)
40
65
  }
41
66
  if (isPrimitive(treeNode) || !isTweakedObject(treeNode, true)) {
@@ -0,0 +1,38 @@
1
+ import type { ObjectChildrenData } from "../parent/coreObjectChildren"
2
+ import type { ParentPath } from "../parent/path"
3
+ import type { SnapshotData } from "../snapshot/internal"
4
+
5
+ /** @internal */
6
+ export interface TreeNodeMetadata {
7
+ tweaked: boolean
8
+ untweaker: (() => void) | undefined
9
+ parentPath: ParentPath<object> | undefined
10
+ dataObjectParent: object | undefined
11
+ objectChildren: ObjectChildrenData | undefined
12
+ snapshot: SnapshotData | undefined
13
+ }
14
+
15
+ /** @internal */
16
+ export const treeNodeMetadata = new WeakMap<object, TreeNodeMetadata>()
17
+
18
+ /** @internal */
19
+ export function getOrCreateTreeNodeMetadata(value: object): TreeNodeMetadata {
20
+ let metadata = treeNodeMetadata.get(value)
21
+ if (!metadata) {
22
+ metadata = {
23
+ tweaked: false,
24
+ untweaker: undefined,
25
+ parentPath: undefined,
26
+ dataObjectParent: undefined,
27
+ objectChildren: undefined,
28
+ snapshot: undefined,
29
+ }
30
+ treeNodeMetadata.set(value, metadata)
31
+ }
32
+ return metadata
33
+ }
34
+
35
+ /** @internal */
36
+ export function markAsTweakedObject(value: object): void {
37
+ getOrCreateTreeNodeMetadata(value).tweaked = true
38
+ }
@@ -8,8 +8,9 @@ import { unsetInternalSnapshot } from "../snapshot/internal"
8
8
  import type { AnyStandardType, TypeToData } from "../types/schemas"
9
9
  import { typeCheck } from "../types/typeCheck"
10
10
  import { failure, inDevMode, isMap, isObject, isPrimitive, isSet } from "../utils"
11
- import { isTweakedObject, tweakedObjects } from "./core"
11
+ import { isTweakedObject, runTweakedObjectUntweakers } from "./core"
12
12
  import { registerDefaultTweakers } from "./registerDefaultTweakers"
13
+ import { treeNodeMetadata } from "./treeNodeMetadata"
13
14
 
14
15
  /**
15
16
  * Turns an object (array, plain object) into a tree node,
@@ -153,7 +154,8 @@ export function tryUntweak(value: any): (() => void) | undefined {
153
154
  }
154
155
  }
155
156
 
156
- const untweaker = tweakedObjects.get(value)
157
+ const metadata = treeNodeMetadata.get(value)
158
+ const untweaker = metadata?.untweaker
157
159
  if (!untweaker) {
158
160
  return undefined
159
161
  }
@@ -176,9 +178,18 @@ export function tryUntweak(value: any): (() => void) | undefined {
176
178
 
177
179
  return () => {
178
180
  // post-untweaking, call the untweaker
179
- untweaker()
181
+ runTweakedObjectUntweakers(untweaker)
180
182
 
181
- tweakedObjects.delete(value)
183
+ // Match the former independent-map ordering: the node stops being tweaked
184
+ // before its snapshot is unset, but the shared record must stay alive until
185
+ // snapshot cleanup has consumed it.
186
+ metadata.tweaked = false
187
+ metadata.untweaker = undefined
182
188
  unsetInternalSnapshot(value)
189
+ // These associations previously lived in independent WeakMaps and remain
190
+ // valid across untweak/retweak, so keep their shared record when present.
191
+ if (metadata.dataObjectParent === undefined && metadata.objectChildren === undefined) {
192
+ treeNodeMetadata.delete(value)
193
+ }
183
194
  }
184
195
  }