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
@@ -5,6 +5,7 @@ import { modelTypeKey } from "../model/metadata"
5
5
  import { getModelOrSnapshotTypeAndId, getSnapshotModelType } from "../model/utils"
6
6
  import type { ModelClass } from "../modelShared/BaseModelShared"
7
7
  import { getModelInfoForName, getModelNotRegisteredErrorMessage } from "../modelShared/modelInfo"
8
+ import { hasOwnProp } from "../utils"
8
9
  import { getCurrentErrorDiagnosticsContext } from "../utils/errorDiagnostics"
9
10
  import { type FromSnapshotContext, registerSnapshotter } from "./fromSnapshot"
10
11
  import type { SnapshotInOfModel } from "./SnapshotOf"
@@ -14,7 +15,7 @@ import { SnapshotterAndReconcilerPriority } from "./SnapshotterAndReconcilerPrio
14
15
  function fromModelSnapshot(sn: SnapshotInOfModel<AnyModel>, ctx: FromSnapshotContext): AnyModel {
15
16
  const type = sn[modelTypeKey]
16
17
 
17
- if (!type) {
18
+ if (type === undefined) {
18
19
  throw new SnapshotProcessingError({
19
20
  message: `a model snapshot must contain a type key (${modelTypeKey}), but none was found`,
20
21
  actualSnapshot: sn,
@@ -30,7 +31,10 @@ function fromModelSnapshot(sn: SnapshotInOfModel<AnyModel>, ctx: FromSnapshotCon
30
31
  }
31
32
 
32
33
  const modelIdPropertyName = getModelIdPropertyName(modelInfo.class as ModelClass<AnyModel>)
33
- if (modelIdPropertyName && sn[modelIdPropertyName] === undefined) {
34
+ if (
35
+ modelIdPropertyName !== undefined &&
36
+ (!hasOwnProp(sn, modelIdPropertyName) || sn[modelIdPropertyName] === undefined)
37
+ ) {
34
38
  throw new SnapshotProcessingError({
35
39
  message: `a model snapshot of type '${type}' must contain an id key (${modelIdPropertyName}), but none was found`,
36
40
  actualSnapshot: sn,
@@ -31,10 +31,13 @@ interface DirtyChild {
31
31
  readonly path: PathElement
32
32
  }
33
33
 
34
- // `false` means the generic freezer must traverse this raw value; `true` means
35
- // it is frozen; a SnapshotData value means it is mutable and its frozen state
36
- // must be reflected back into that owner. `undefined` is either external /
37
- // untracked data or a fused frozen clone, whose owner tracks its state directly.
34
+ // `false` means the generic freezer must traverse this raw value; `true` means it
35
+ // is frozen; a SnapshotData value means it is mutable and owned by that snapshot,
36
+ // which is where its frozen state must be reflected back. Both the untransformed
37
+ // and the transformed value of a snapshot point to their owner, so freezing can
38
+ // reach descendants that are only shared through processor wrappers. `undefined`
39
+ // is either external / untracked data or a fused frozen clone, whose owner tracks
40
+ // its state directly.
38
41
  const snapshotStates = new WeakMap<object, boolean | SnapshotData>()
39
42
 
40
43
  /**
@@ -101,7 +104,7 @@ export const setNewInternalSnapshot = action(
101
104
  snapshotStates.set(untransformed, markAsFrozen ? true : sn)
102
105
 
103
106
  if (transformed !== undefined && transformed !== untransformed) {
104
- snapshotStates.set(transformed, markAsFrozen)
107
+ snapshotStates.set(transformed, markAsFrozen ? true : sn)
105
108
  }
106
109
 
107
110
  getOrCreateTreeNodeMetadata(value).snapshot = sn
@@ -143,7 +146,7 @@ function setSnapshotData(sn: SnapshotData, untransformed: any, freezeIfCloned =
143
146
  transformed !== undefined &&
144
147
  (!wasFrozen || transformed !== untransformed)
145
148
  ) {
146
- snapshotStates.set(transformed, false)
149
+ snapshotStates.set(transformed, sn)
147
150
  }
148
151
  }
149
152
 
@@ -335,7 +338,7 @@ export const refreshInternalSnapshot = action("refreshInternalSnapshot", (value:
335
338
  // transformed snapshots created by internal transforms must be tracked as mutable
336
339
  // until they are exposed through getSnapshot / freezeInternalSnapshot.
337
340
  if (snapshotStates.get(newTransformed) === undefined) {
338
- snapshotStates.set(newTransformed, false)
341
+ snapshotStates.set(newTransformed, sn)
339
342
  }
340
343
 
341
344
  sn.atom?.reportChanged()
@@ -370,6 +373,16 @@ export function freezeInternalSnapshot<T extends PrimitiveValue | object>(data:
370
373
  return data
371
374
  }
372
375
 
376
+ const owner = state === false ? undefined : state
377
+
378
+ // Processors can create untracked wrappers around tracked descendants, and the
379
+ // freezer stops at those (their state is `undefined`). Freeze the source of the
380
+ // transformed value as well, so shared descendants that the transformed
381
+ // traversal cannot reach still switch to copy-on-write.
382
+ if (owner !== undefined && owner.untransformed !== data) {
383
+ freezeInternalSnapshot(owner.untransformed)
384
+ }
385
+
373
386
  if (Array.isArray(data)) {
374
387
  for (let i = 0; i < data.length; i++) {
375
388
  freezeInternalSnapshot(data[i])
@@ -383,8 +396,8 @@ export function freezeInternalSnapshot<T extends PrimitiveValue | object>(data:
383
396
 
384
397
  snapshotStates.set(data, true)
385
398
 
386
- if (typeof state === "object" && state.untransformed === data) {
387
- state.untransformedFrozen = true
399
+ if (owner !== undefined && owner.untransformed === data) {
400
+ owner.untransformedFrozen = true
388
401
  }
389
402
 
390
403
  return data
@@ -31,9 +31,20 @@ export function onSnapshot<T extends object>(
31
31
  assertTweakedObject(node, "node")
32
32
 
33
33
  let currentSnapshot = getSnapshot(node)
34
+ let firstTrackingRun = true
34
35
 
35
36
  return reaction(
36
- () => getSnapshot(nodeFn()),
37
+ () => {
38
+ const snapshot = getSnapshot(nodeFn())
39
+ // Registration inside an action defers the first tracking run until the
40
+ // action ends. Its snapshot, rather than the earlier registration state,
41
+ // is the baseline for the first notification.
42
+ if (firstTrackingRun) {
43
+ firstTrackingRun = false
44
+ currentSnapshot = snapshot
45
+ }
46
+ return snapshot
47
+ },
37
48
  (newSnapshot) => {
38
49
  const prevSn = currentSnapshot
39
50
  currentSnapshot = newSnapshot
@@ -17,7 +17,7 @@ import {
17
17
  runTypeCheckingAfterChange,
18
18
  } from "../tweaker/typeChecking"
19
19
  import { withoutTypeChecking } from "../tweaker/withoutTypeChecking"
20
- import { isArray, isPrimitive } from "../utils"
20
+ import { hasOwnProp, isArray, isPrimitive } from "../utils"
21
21
  import { withErrorModelTrailEntry, withErrorPathSegment } from "../utils/errorDiagnostics"
22
22
  import type { ModelPool } from "../utils/ModelPool"
23
23
  import { setIfDifferent } from "../utils/setIfDifferent"
@@ -58,15 +58,15 @@ function reconcileModelSnapshot(
58
58
  }
59
59
 
60
60
  const modelIdPropertyName = getModelIdPropertyName(modelClass)
61
- const incomingModelId = modelIdPropertyName ? sn[modelIdPropertyName] : undefined
61
+ const incomingModelId = modelIdPropertyName !== undefined ? sn[modelIdPropertyName] : undefined
62
62
  const positionalModelMatches =
63
63
  isModel(value) &&
64
64
  value[modelTypeKey] === type &&
65
- (!modelIdPropertyName || value[modelIdKey] === incomingModelId)
65
+ (modelIdPropertyName === undefined || value[modelIdKey] === incomingModelId)
66
66
 
67
67
  // The positional model is already the desired instance in the common
68
68
  // stable-update case. Only moved identified models need a pool lookup.
69
- if (!positionalModelMatches && modelIdPropertyName) {
69
+ if (!positionalModelMatches && modelIdPropertyName !== undefined) {
70
70
  const modelInPool = modelPool.findModelByTypeAndId(type, incomingModelId)
71
71
  if (modelInPool) {
72
72
  value = modelInPool
@@ -92,7 +92,7 @@ function reconcileModelSnapshot(
92
92
  }
93
93
 
94
94
  const modelProps = getInternalModelClassPropsInfo(modelClass)
95
- if (modelIdPropertyName) {
95
+ if (modelIdPropertyName !== undefined) {
96
96
  if (value[modelIdKey] !== incomingModelId) {
97
97
  // different id, no reconciliation possible
98
98
  return fromSnapshot<AnyModel>(sn)
@@ -134,7 +134,7 @@ function reconcileModelSnapshot(
134
134
  const dataKeysLen = dataKeys.length
135
135
  for (let i = 0; i < dataKeysLen; i++) {
136
136
  const k = dataKeys[i]
137
- if (!(k in processedSn)) {
137
+ if (!hasOwnProp(processedSn, k)) {
138
138
  // use default value if applicable
139
139
  const modelProp = modelProps[k] as AnyModelProp | undefined
140
140
  const defaultValue = modelProp
@@ -1,10 +1,11 @@
1
1
  import { isObservableObject, remove } from "mobx"
2
+ import { isModel } from "../model/utils"
2
3
  import {
3
4
  isTypeCheckingAfterChangeEnabled,
4
5
  runTypeCheckingAfterChange,
5
6
  } from "../tweaker/typeChecking"
6
7
  import { withoutTypeChecking } from "../tweaker/withoutTypeChecking"
7
- import { isPlainObject } from "../utils"
8
+ import { hasOwnProp, isPlainObject } from "../utils"
8
9
  import { withErrorPathSegment } from "../utils/errorDiagnostics"
9
10
  import type { ModelPool } from "../utils/ModelPool"
10
11
  import { setIfDifferent } from "../utils/setIfDifferent"
@@ -19,8 +20,8 @@ function reconcilePlainObjectSnapshot(
19
20
  sn: SnapshotInOfObject<any>,
20
21
  modelPool: ModelPool
21
22
  ): object {
22
- // plain obj
23
- if (!(isPlainObject(value) || isObservableObject(value))) {
23
+ // Observable models carry instance state and must be replaced, not edited as plain objects.
24
+ if (isModel(value) || !(isPlainObject(value) || isObservableObject(value))) {
24
25
  // no reconciliation possible
25
26
  return fromSnapshot(sn)
26
27
  }
@@ -35,7 +36,7 @@ function reconcilePlainObjectSnapshot(
35
36
  const plainObjKeysLen = plainObjKeys.length
36
37
  for (let i = 0; i < plainObjKeysLen; i++) {
37
38
  const k = plainObjKeys[i]
38
- if (!(k in sn)) {
39
+ if (!hasOwnProp(sn, k)) {
39
40
  remove(plainObj, k)
40
41
  }
41
42
  }
@@ -26,7 +26,7 @@ export const arrayActions = {
26
26
 
27
27
  concat: standaloneAction(
28
28
  `${namespace}::concat`,
29
- <T>(array: T[], ...items: ConcatArray<T>[]): T[] => {
29
+ <T>(array: T[], ...items: (T | ConcatArray<T>)[]): T[] => {
30
30
  return array.concat(...items)
31
31
  }
32
32
  ),
@@ -89,9 +89,19 @@ export const arrayActions = {
89
89
  swap: standaloneAction(
90
90
  `${namespace}::swap`,
91
91
  <T>(array: T[], index1: number, index2: number): boolean => {
92
- if (index1 < 0 || index2 < 0 || index1 >= array.length || index2 >= array.length) {
92
+ if (
93
+ !Number.isInteger(index1) ||
94
+ !Number.isInteger(index2) ||
95
+ index1 < 0 ||
96
+ index2 < 0 ||
97
+ index1 >= array.length ||
98
+ index2 >= array.length
99
+ ) {
93
100
  return false
94
101
  }
102
+ if (index1 === index2) {
103
+ return true
104
+ }
95
105
  if (index2 < index1) {
96
106
  ;[index1, index2] = [index2, index1]
97
107
  }
@@ -1,4 +1,5 @@
1
1
  import { isObservable, remove } from "mobx"
2
+ import { isModel } from "../model/utils"
2
3
  import { toTreeNode } from "../tweaker/tweak"
3
4
  import { assertIsObject, namespace as ns } from "../utils"
4
5
  import type { AnyFunction } from "../utils/AnyFunction"
@@ -11,7 +12,7 @@ export const objectActions = {
11
12
  set: standaloneAction(
12
13
  `${namespace}::set`,
13
14
  <T extends object, K extends keyof T>(target: T, key: K, value: T[K]): void => {
14
- if (isObservable(target)) {
15
+ if (isObservable(target) && !isModel(target)) {
15
16
  setIfDifferent(target, key, value)
16
17
  } else {
17
18
  target[key] = value
@@ -25,7 +26,7 @@ export const objectActions = {
25
26
  assertIsObject(partialObject, "partialObject")
26
27
  const keys = Object.keys(partialObject)
27
28
 
28
- if (isObservable(target)) {
29
+ if (isObservable(target) && !isModel(target)) {
29
30
  for (const key of keys) {
30
31
  const newValue = (partialObject as any)[key]
31
32
  setIfDifferent(target, key, newValue)
@@ -31,6 +31,10 @@ const errMessage = "this Date object is immutable"
31
31
  export class ImmutableDate extends Date {
32
32
  // disable mutable methods
33
33
 
34
+ setYear(): never {
35
+ throw failure(errMessage)
36
+ }
37
+
34
38
  setTime(): any {
35
39
  throw failure(errMessage)
36
40
  }
@@ -1,4 +1,5 @@
1
1
  import type { ModelPropTransform } from "../modelShared/prop"
2
+ import { setProtoProp } from "../utils"
2
3
  import { asMap } from "../wrappers/asMap"
3
4
 
4
5
  const _objectToMapTransform: ModelPropTransform<Record<string, unknown>, Map<string, unknown>> = {
@@ -11,7 +12,8 @@ const _objectToMapTransform: ModelPropTransform<Record<string, unknown>, Map<str
11
12
 
12
13
  const obj: Record<string, unknown> = {}
13
14
  map.forEach((v, k) => {
14
- obj[k] = v
15
+ if (k === "__proto__") setProtoProp(obj, v)
16
+ else obj[k] = v
15
17
  })
16
18
 
17
19
  return obj
@@ -29,21 +29,23 @@ export function deepEquals(a: any, b: any): boolean {
29
29
  return true
30
30
  }
31
31
 
32
- // use snapshots to compare if possible
33
- // since snapshots use structural sharing it is more likely
34
- // to speed up comparisons
35
- if (isTreeNode(a)) {
36
- a = getSnapshot(a)
37
- } else if (isObservable(a)) {
38
- a = toJS(a, toJSOptions)
39
- }
40
- if (isTreeNode(b)) {
41
- b = getSnapshot(b)
42
- } else if (isObservable(b)) {
43
- b = toJS(b, toJSOptions)
32
+ const aIsObject = a !== null && typeof a === "object"
33
+ const bIsObject = b !== null && typeof b === "object"
34
+ if (!aIsObject && !bIsObject) {
35
+ // Strict equality already handled all equal primitives except NaN.
36
+ return Number.isNaN(a) && Number.isNaN(b)
44
37
  }
45
38
 
46
- return fastDeepEqual(a, b)
39
+ return fastDeepEqual(aIsObject ? comparisonValue(a) : a, bIsObject ? comparisonValue(b) : b)
40
+ }
41
+
42
+ function comparisonValue(value: object): unknown {
43
+ if (isTreeNode(value)) {
44
+ // Snapshots preserve structural sharing. Model $ objects are deliberately
45
+ // handled as observable objects rather than standalone tree nodes.
46
+ return getSnapshot(value)
47
+ }
48
+ return isObservable(value) ? toJS(value, toJSOptions) : value
47
49
  }
48
50
 
49
51
  const toJSOptions =
@@ -38,6 +38,10 @@ export class Draft<T extends object> {
38
38
  */
39
39
  @mobxAction
40
40
  commitByPath(path: Path): void {
41
+ if (path.length === 0) {
42
+ this.commit()
43
+ return
44
+ }
41
45
  const draftTarget = resolvePath(this.data, path)
42
46
  if (!draftTarget.resolved) {
43
47
  throw failure(`path ${JSON.stringify(path)} could not be resolved in draft object`)
@@ -76,6 +80,10 @@ export class Draft<T extends object> {
76
80
  */
77
81
  @mobxAction
78
82
  resetByPath(path: Path): void {
83
+ if (path.length === 0) {
84
+ this.reset()
85
+ return
86
+ }
79
87
  const originalTarget = resolvePath(this.originalData, path)
80
88
  if (!originalTarget.resolved) {
81
89
  throw failure(`path ${JSON.stringify(path)} could not be resolved in original object`)
@@ -7,7 +7,6 @@ import { createContext } from "../context"
7
7
  import { getParentToChildPath, resolvePath } from "../parent/path"
8
8
  import { applyPatches } from "../patch/applyPatches"
9
9
  import { onPatches } from "../patch/emitPatch"
10
- import type { Patch } from "../patch/Patch"
11
10
  import { type PatchRecorder, patchRecorder } from "../patch/patchRecorder"
12
11
  import {
13
12
  fastIsRootStoreNoAtom,
@@ -78,11 +77,6 @@ export class SandboxManager {
78
77
  */
79
78
  private allowWrite: ReadonlyMiddlewareReturn["allowWrite"]
80
79
 
81
- /**
82
- * Whether changes made in the sandbox are currently being committed to the original subtree.
83
- */
84
- private isCommitting = false
85
-
86
80
  private readonly subtreeRoot: object
87
81
 
88
82
  /**
@@ -127,11 +121,9 @@ export class SandboxManager {
127
121
  if (this.withSandboxPatchRecorder) {
128
122
  throw failure("original subtree must not change while 'withSandbox' executes")
129
123
  }
130
- if (!this.isCommitting) {
131
- this.allowWrite(() => {
132
- applyPatches(this.subtreeRootClone, patches)
133
- })
134
- }
124
+ this.allowWrite(() => {
125
+ applyPatches(this.subtreeRootClone, patches)
126
+ })
135
127
  })
136
128
 
137
129
  const { allowWrite, dispose: disposeReadonlyMW } = readonlyMiddleware(this.subtreeRootClone)
@@ -232,26 +224,38 @@ export class SandboxManager {
232
224
  }
233
225
  if (commit) {
234
226
  if (!isNestedWithSandboxCall) {
235
- const patches: Patch[] = []
236
- const len = recorder.events.length
237
- for (let i = 0; i < len; i++) {
238
- patches.push(...recorder.events[i].patches)
239
- }
240
-
241
- const isCommitting = this.isCommitting
242
- this.isCommitting = true
243
- try {
244
- applyPatches(this.subtreeRoot, patches)
245
- } finally {
246
- this.isCommitting = isCommitting
247
- }
227
+ const events = recorder.events
228
+ // The clone already holds these changes, so revert them and let the regular
229
+ // patch sync mirror the original's final state back in. That way anything a
230
+ // listener changes on the original during the commit reaches the clone too,
231
+ // at a cost proportional to the changes rather than to the tree size.
232
+ // Both halves run in a single action so that observers of the sandbox never
233
+ // see the reverted intermediate state.
234
+ runInAction(() => {
235
+ this.allowWrite(() => {
236
+ for (let i = events.length - 1; i >= 0; i--) {
237
+ applyPatches(this.subtreeRootClone, events[i].inversePatches, true)
238
+ }
239
+ })
240
+ applyPatches(
241
+ this.subtreeRoot,
242
+ events.map((event) => event.patches)
243
+ )
244
+ })
248
245
  }
249
246
  } else {
250
247
  this.allowWrite(() => {
251
248
  runInAction(() => {
252
- let i = recorder.events.length
253
- while (i-- > numRecorderEvents) {
254
- applyPatches(this.subtreeRootClone, recorder.events[i].inversePatches, true)
249
+ const wasRecording = recorder.recording
250
+ recorder.recording = false
251
+ try {
252
+ let i = recorder.events.length
253
+ while (i-- > numRecorderEvents) {
254
+ applyPatches(this.subtreeRootClone, recorder.events[i].inversePatches, true)
255
+ }
256
+ recorder.events.splice(numRecorderEvents)
257
+ } finally {
258
+ recorder.recording = wasRecording
255
259
  }
256
260
  })
257
261
  })
@@ -28,10 +28,8 @@ export function setTweakedObjectUntweakers(
28
28
  * @internal
29
29
  */
30
30
  export function isTweakedObject(value: unknown, canBeDataObject: boolean): value is object {
31
- if (!canBeDataObject && hasDataObjectParent(value as object)) {
32
- return false
33
- }
34
- return treeNodeMetadata.get(value as object)?.tweaked === true
31
+ const metadata = treeNodeMetadata.get(value as object)
32
+ return metadata?.tweaked === true && (canBeDataObject || metadata.dataObjectParent === undefined)
35
33
  }
36
34
 
37
35
  /**
@@ -8,6 +8,7 @@ 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 { getSafeErrorValuePreview } from "../utils/errorDiagnostics"
11
12
  import { isTweakedObject, runTweakedObjectUntweakers } from "./core"
12
13
  import { registerDefaultTweakers } from "./registerDefaultTweakers"
13
14
  import { treeNodeMetadata } from "./treeNodeMetadata"
@@ -126,7 +127,7 @@ function internalTweak<T>(value: T, parentPath: ParentPath<any> | undefined): T
126
127
  }
127
128
 
128
129
  throw failure(
129
- `tweak can only work over models, observable objects/arrays, or primitives, but got ${value} instead`
130
+ `tweak can only work over models, observable objects/arrays, or primitives, but got ${getSafeErrorValuePreview(value)} instead`
130
131
  )
131
132
  }
132
133
 
@@ -13,7 +13,13 @@ import { emitArraySpliceDeepChange, emitArrayUpdateDeepChange } from "../deepCha
13
13
  import { getGlobalConfig } from "../globalConfig"
14
14
  import type { ParentPath } from "../parent/path"
15
15
  import { reindexArrayChildren, setParent } from "../parent/setParent"
16
- import { hasPatchListenersFor, InternalPatchRecorder } from "../patch/emitPatch"
16
+ import {
17
+ beginPatchEmission,
18
+ emitPatches,
19
+ endPatchEmission,
20
+ hasPatchListenersFor,
21
+ InternalPatchRecorder,
22
+ } from "../patch/emitPatch"
17
23
  import type { Patch } from "../patch/Patch"
18
24
  import {
19
25
  flushInternalSnapshot,
@@ -23,11 +29,20 @@ import {
23
29
  updateInternalSnapshot,
24
30
  } from "../snapshot/internal"
25
31
  import { failure, inDevMode, isArray, isPrimitive } from "../utils"
32
+ import {
33
+ addDelayedError,
34
+ type DelayedError,
35
+ throwDelayedError,
36
+ } from "../utils/forEachWithDelayedThrow"
26
37
  import { runningWithoutSnapshotOrPatches, setTweakedObjectUntweakers } from "./core"
27
38
  import { TweakerPriority } from "./TweakerPriority"
28
39
  import { markAsTweakedObject } from "./treeNodeMetadata"
29
40
  import { registerTweaker, tweak } from "./tweak"
30
- import { isTypeCheckingAfterChangeEnabled, runTypeCheckingAfterChange } from "./typeChecking"
41
+ import {
42
+ isTypeCheckingAfterChangeEnabled,
43
+ recordTypeCheckingBatchChange,
44
+ runTypeCheckingAfterChange,
45
+ } from "./typeChecking"
31
46
 
32
47
  /**
33
48
  * @internal
@@ -138,8 +153,23 @@ function mutateSet(k: number, v: unknown, sn: unknown[]) {
138
153
  sn[k] = v
139
154
  }
140
155
 
156
+ // Conservative bound below every engine's argument-count limit for spread calls.
157
+ const maxSpreadableItems = 8192
158
+
141
159
  function mutateSplice(index: number, removedCount: number, addedItems: any[], sn: any[]) {
142
- sn.splice(index, removedCount, ...addedItems)
160
+ if (addedItems.length < maxSpreadableItems) {
161
+ sn.splice(index, removedCount, ...addedItems)
162
+ return
163
+ }
164
+
165
+ // Splice the tail in a single pass instead of spreading the added items as
166
+ // arguments, which would overflow the engine's limit. copyWithin handles overlap.
167
+ const oldLength = sn.length
168
+ const newLength = oldLength - removedCount + addedItems.length
169
+ if (newLength > oldLength) sn.length = newLength
170
+ sn.copyWithin(index + addedItems.length, index + removedCount, oldLength)
171
+ for (let i = 0; i < addedItems.length; i++) sn[index + i] = addedItems[i]
172
+ sn.length = newLength
143
173
  }
144
174
 
145
175
  const patchRecorder = new InternalPatchRecorder()
@@ -149,24 +179,12 @@ function arrayDidChange(change: IArrayDidChange) {
149
179
 
150
180
  patchRecorder.reset()
151
181
 
152
- switch (change.type) {
153
- case "splice":
154
- emitArraySpliceDeepChange(arr, arr, change.index as number, change.added, change.removed)
155
- break
156
-
157
- case "update":
158
- emitArrayUpdateDeepChange(arr, arr, change.index as number, change.newValue, change.oldValue)
159
-
160
- break
161
-
162
- default:
163
- break
164
- }
165
-
166
182
  if (runningWithoutSnapshotOrPatches) {
167
183
  return
168
184
  }
169
185
 
186
+ recordTypeCheckingBatchChange(change)
187
+
170
188
  const oldSnapshot = getInternalSnapshot(arr as Array<unknown>)!.untransformed
171
189
  const shouldEmitPatches = hasPatchListenersFor(arr)
172
190
  const shouldBuildInversePatches = shouldEmitPatches || isTypeCheckingAfterChangeEnabled()
@@ -196,8 +214,53 @@ function arrayDidChange(change: IArrayDidChange) {
196
214
  runTypeCheckingAfterChange(arr, patchRecorder)
197
215
  if (mutate) {
198
216
  updateInternalSnapshot(arr, mutate)
199
- patchRecorder.emit(arr)
200
217
  }
218
+ // Listeners may mutate another node and reuse the shared recorder.
219
+ // Keep this change's patches before publishing the validated deep change.
220
+ const { patches, invPatches } = patchRecorder
221
+ patchRecorder.reset()
222
+
223
+ // Reserve chronological patch notifications, then finish deep-change delivery
224
+ // before publishing them. Reentrant edits append behind this mutation.
225
+ // Every step below runs even when an earlier one throws, so a failing patch recorder
226
+ // cannot keep the mutation from reaching the deep change and public patch listeners,
227
+ // and no failure is lost to a later one.
228
+ beginPatchEmission()
229
+ let delayedError: DelayedError
230
+ try {
231
+ emitPatches(arr, patches, invPatches)
232
+ } catch (error) {
233
+ delayedError = addDelayedError(delayedError, error)
234
+ }
235
+ try {
236
+ switch (change.type) {
237
+ case "splice":
238
+ emitArraySpliceDeepChange(arr, arr, change.index as number, change.added, change.removed)
239
+ break
240
+
241
+ case "update":
242
+ emitArrayUpdateDeepChange(
243
+ arr,
244
+ arr,
245
+ change.index as number,
246
+ change.newValue,
247
+ change.oldValue
248
+ )
249
+
250
+ break
251
+
252
+ default:
253
+ break
254
+ }
255
+ } catch (error) {
256
+ delayedError = addDelayedError(delayedError, error)
257
+ }
258
+ try {
259
+ endPatchEmission()
260
+ } catch (error) {
261
+ delayedError = addDelayedError(delayedError, error)
262
+ }
263
+ throwDelayedError(delayedError)
201
264
  }
202
265
 
203
266
  const undefinedInsideArrayErrorMsg =
@@ -317,10 +380,10 @@ function arrayDidChangeSplice(
317
380
  }
318
381
 
319
382
  if (patches && readdPatches && readdPatches.length > 0) {
320
- patches.push(...readdPatches)
383
+ for (const patch of readdPatches) patches.push(patch)
321
384
  }
322
385
  if (invPatches && readdInvPatches && readdInvPatches.length > 0) {
323
- invPatches.push(...readdInvPatches)
386
+ for (const patch of readdInvPatches) invPatches.push(patch)
324
387
  }
325
388
  // We need to reverse once since inverse patches are applied in reverse.
326
389
  // Repeated unshift calls here would make a large splice quadratic.