mobx-keystone 1.25.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/dist/mobx-keystone.esm.js +3627 -1540
  3. package/dist/mobx-keystone.esm.mjs +3627 -1540
  4. package/dist/mobx-keystone.umd.js +3638 -1543
  5. package/dist/types/actionMiddlewares/actionSerialization/dateSerializer.d.ts +1 -1
  6. package/dist/types/actionMiddlewares/transactionMiddleware.d.ts +1 -1
  7. package/dist/types/actionMiddlewares/undoMiddleware.d.ts +17 -10
  8. package/dist/types/dataModel/dataModelInstanceCache.d.ts +1 -0
  9. package/dist/types/deepChange/onDeepChange.d.ts +9 -0
  10. package/dist/types/parent/arrayPaths.d.ts +1 -0
  11. package/dist/types/parent/detach.d.ts +1 -1
  12. package/dist/types/parent/findChildren.d.ts +1 -1
  13. package/dist/types/parent/treeOrder.d.ts +1 -0
  14. package/dist/types/patch/applyPatches.d.ts +3 -0
  15. package/dist/types/redux/connectReduxDevTools.d.ts +2 -1
  16. package/dist/types/ref/modelRefIdIndex.d.ts +1 -0
  17. package/dist/types/ref/nodesById.d.ts +1 -0
  18. package/dist/types/ref/refIdIndex.d.ts +1 -0
  19. package/dist/types/snapshot/SnapshotOf.d.ts +2 -2
  20. package/dist/types/snapshot/applySnapshot.d.ts +1 -0
  21. package/dist/types/snapshot/fromSnapshot.d.ts +0 -3
  22. package/dist/types/standardActions/arrayActions.d.ts +1 -1
  23. package/dist/types/treeUtils/sandbox.d.ts +0 -4
  24. package/dist/types/tweaker/changeRollback.d.ts +1 -0
  25. package/dist/types/tweaker/tweakedChangeListeners.d.ts +1 -0
  26. package/dist/types/types/TypeCheckError.d.ts +1 -0
  27. package/dist/types/types/utility/typesRefinement.d.ts +2 -2
  28. package/dist/types/utils/ModelPool.d.ts +7 -1
  29. package/dist/types/utils/chunks.d.ts +1 -0
  30. package/dist/types/utils/forEachWithDelayedThrow.d.ts +1 -0
  31. package/dist/types/utils/index.d.ts +13 -0
  32. package/dist/types/utils/mapUtils.d.ts +2 -2
  33. package/dist/types/utils/setLike.d.ts +1 -0
  34. package/package.json +2 -2
  35. package/src/action/applyAction.ts +1 -1
  36. package/src/action/middleware.ts +17 -36
  37. package/src/action/modelFlow.ts +4 -5
  38. package/src/action/mutationBatch.ts +10 -10
  39. package/src/action/pendingActions.ts +3 -4
  40. package/src/actionMiddlewares/actionSerialization/actionSerialization.ts +19 -14
  41. package/src/actionMiddlewares/actionSerialization/applySerializedAction.ts +29 -20
  42. package/src/actionMiddlewares/actionSerialization/arraySerializer.ts +1 -1
  43. package/src/actionMiddlewares/actionSerialization/dateSerializer.ts +4 -3
  44. package/src/actionMiddlewares/actionSerialization/mapSerializer.ts +4 -12
  45. package/src/actionMiddlewares/actionSerialization/setSerializer.ts +4 -13
  46. package/src/actionMiddlewares/actionTrackingMiddleware.ts +11 -13
  47. package/src/actionMiddlewares/transactionMiddleware.ts +55 -25
  48. package/src/actionMiddlewares/undoMiddleware.ts +112 -59
  49. package/src/actionMiddlewares/utils.ts +2 -1
  50. package/src/computedTree/computedTree.ts +14 -3
  51. package/src/context/context.ts +17 -45
  52. package/src/dataModel/BaseDataModel.ts +20 -16
  53. package/src/dataModel/dataModelInstanceCache.ts +8 -0
  54. package/src/deepChange/onDeepChange.ts +179 -24
  55. package/src/frozen/Frozen.ts +50 -33
  56. package/src/model/BaseModel.ts +10 -13
  57. package/src/model/newModel.ts +21 -24
  58. package/src/model/utils.ts +2 -2
  59. package/src/modelShared/modelClassInitializer.ts +3 -2
  60. package/src/modelShared/modelDecorator.ts +39 -39
  61. package/src/modelShared/modelInfo.ts +1 -1
  62. package/src/modelShared/prop.ts +6 -9
  63. package/src/modelShared/sharedInternalModel.ts +16 -3
  64. package/src/parent/arrayPaths.ts +242 -0
  65. package/src/parent/coreObjectChildren.ts +300 -48
  66. package/src/parent/detach.ts +1 -1
  67. package/src/parent/findChildren.ts +4 -7
  68. package/src/parent/onChildAttachedTo.ts +157 -55
  69. package/src/parent/path.ts +15 -5
  70. package/src/parent/setParent.ts +34 -15
  71. package/src/parent/treeOrder.ts +53 -0
  72. package/src/parent/walkTree.ts +12 -101
  73. package/src/patch/applyPatches.ts +74 -34
  74. package/src/patch/emitPatch.ts +142 -26
  75. package/src/patch/jsonPatch.ts +5 -0
  76. package/src/patch/patchRecorder.ts +18 -14
  77. package/src/redux/connectReduxDevTools.ts +24 -18
  78. package/src/redux/redux.ts +5 -21
  79. package/src/ref/core.ts +81 -29
  80. package/src/ref/modelRefIdIndex.ts +131 -0
  81. package/src/ref/nodesById.ts +60 -0
  82. package/src/ref/refIdIndex.ts +209 -0
  83. package/src/rootStore/attachDetach.ts +27 -16
  84. package/src/rootStore/rootStore.ts +10 -6
  85. package/src/snapshot/SnapshotOf.ts +2 -2
  86. package/src/snapshot/applySnapshot.ts +27 -19
  87. package/src/snapshot/clone.ts +1 -2
  88. package/src/snapshot/cloneTreeValue.ts +1 -2
  89. package/src/snapshot/fromModelSnapshot.ts +6 -2
  90. package/src/snapshot/fromPlainObjectSnapshot.ts +3 -8
  91. package/src/snapshot/fromSnapshot.ts +2 -6
  92. package/src/snapshot/internal.ts +89 -19
  93. package/src/snapshot/onSnapshot.ts +12 -1
  94. package/src/snapshot/reconcileModelSnapshot.ts +6 -6
  95. package/src/snapshot/reconcilePlainObjectSnapshot.ts +5 -4
  96. package/src/standardActions/arrayActions.ts +12 -2
  97. package/src/standardActions/objectActions.ts +3 -2
  98. package/src/transforms/ImmutableDate.ts +4 -0
  99. package/src/transforms/asMap.ts +3 -1
  100. package/src/treeUtils/deepEquals.ts +15 -13
  101. package/src/treeUtils/draft.ts +8 -0
  102. package/src/treeUtils/sandbox.ts +31 -27
  103. package/src/tweaker/changeRollback.ts +218 -0
  104. package/src/tweaker/core.ts +33 -22
  105. package/src/tweaker/treeNodeMetadata.ts +12 -2
  106. package/src/tweaker/tweak.ts +183 -19
  107. package/src/tweaker/tweakArray.ts +176 -33
  108. package/src/tweaker/tweakPlainObject.ts +117 -39
  109. package/src/tweaker/tweakedChangeListeners.ts +61 -0
  110. package/src/tweaker/typeChecking.ts +25 -15
  111. package/src/types/TypeCheckError.ts +17 -19
  112. package/src/types/arrayBased/typesArray.ts +10 -11
  113. package/src/types/createCachedTypeCheck.ts +541 -88
  114. package/src/types/objectBased/typesDataModelData.ts +4 -2
  115. package/src/types/objectBased/typesModel.ts +4 -2
  116. package/src/types/objectBased/typesObject.ts +20 -16
  117. package/src/types/objectBased/typesRecord.ts +8 -18
  118. package/src/types/objectBased/typesRef.ts +2 -2
  119. package/src/types/primitiveBased/typesEnum.ts +4 -2
  120. package/src/types/primitiveBased/typesPrimitive.ts +9 -4
  121. package/src/types/typeCheckErrorUtils.ts +1 -0
  122. package/src/types/utility/typesCodecSupport.ts +97 -56
  123. package/src/types/utility/typesMap.ts +24 -41
  124. package/src/types/utility/typesRefinement.ts +3 -2
  125. package/src/types/utility/typesSet.ts +24 -0
  126. package/src/utils/ModelPool.ts +78 -22
  127. package/src/utils/chainFns.ts +4 -0
  128. package/src/utils/chunks.ts +271 -0
  129. package/src/utils/decorators.ts +1 -1
  130. package/src/utils/errorDiagnostics.ts +6 -2
  131. package/src/utils/forEachWithDelayedThrow.ts +83 -0
  132. package/src/utils/index.ts +71 -2
  133. package/src/utils/mapUtils.ts +3 -7
  134. package/src/utils/setIfDifferent.ts +3 -2
  135. package/src/utils/setLike.ts +134 -0
  136. package/src/wrappers/ArraySet.ts +5 -9
  137. package/src/wrappers/ObjectMap.ts +15 -11
  138. package/src/wrappers/asMap.ts +30 -18
  139. package/src/wrappers/asSet.ts +36 -12
  140. package/dist/types/action/modelFlowPromiseGenerator.d.ts +0 -1
  141. package/src/action/modelFlowPromiseGenerator.ts +0 -127
@@ -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
  })
@@ -0,0 +1,218 @@
1
+ import { type IArrayDidChange, type IObjectDidChange, remove, set } from "mobx"
2
+ import type { AnyModel } from "../model/BaseModel"
3
+ import type { DelayedError } from "../utils/forEachWithDelayedThrow"
4
+ import { forEachTypedModelAncestor, isTypeCheckingAfterChangeEnabled } from "./typeChecking"
5
+ import { withoutTypeChecking } from "./withoutTypeChecking"
6
+
7
+ // Rolling back tree changes that failed partway (not to be confused with the
8
+ // undo manager, which records user-level history): a change batch makes
9
+ // `applySnapshot` / `applyPatches` all or nothing, and rollback recorders make
10
+ // transactions revertible. Both restore the live values each change replaced.
11
+
12
+ interface ChangeBatch {
13
+ /** Whether the changed models are type checked when the batch completes. */
14
+ validate: boolean
15
+ models: Set<AnyModel>
16
+ /** How to revert each recorded change, oldest first. */
17
+ reverts: (() => void)[]
18
+ recording: boolean
19
+ /** Last recorded change target, so a run of changes to it walks ancestors once. */
20
+ previousTarget?: object
21
+ }
22
+
23
+ let changeBatch: ChangeBatch | undefined
24
+
25
+ const noListenerError = Symbol("noListenerError")
26
+ let lastListenerError: unknown = noListenerError
27
+
28
+ /**
29
+ * @internal
30
+ * Rethrows the errors thrown by the listeners of an applied change. A change
31
+ * batch does not roll back for them: the change itself was valid.
32
+ */
33
+ export function throwDelayedListenerError(delayedError: DelayedError): void {
34
+ if (delayedError) {
35
+ lastListenerError = delayedError.value
36
+ throw delayedError.value
37
+ }
38
+ }
39
+
40
+ /**
41
+ * @internal
42
+ * Leaves the type check of a new model to the validating change batch being
43
+ * recorded, if any. Returns whether it did.
44
+ */
45
+ export function deferModelTypeCheckToBatch(model: AnyModel): boolean {
46
+ if (changeBatch?.recording && changeBatch.validate) {
47
+ changeBatch.models.add(model)
48
+ return true
49
+ }
50
+ return false
51
+ }
52
+
53
+ /**
54
+ * @internal
55
+ * Records how to revert every change made while `recording` is set, wherever
56
+ * it happens, until disposed. Used to roll back failed transactions.
57
+ */
58
+ export class ChangeRollbackRecorder {
59
+ recording = false
60
+ readonly reverts: (() => void)[] = []
61
+
62
+ constructor() {
63
+ rollbackRecorders.add(this)
64
+ }
65
+
66
+ dispose(): void {
67
+ rollbackRecorders.delete(this)
68
+ }
69
+ }
70
+
71
+ const rollbackRecorders = new Set<ChangeRollbackRecorder>()
72
+
73
+ // (a suspended transaction keeps its recorder without recording)
74
+ function isAnyRollbackRecorderRecording(): boolean {
75
+ for (const recorder of rollbackRecorders) {
76
+ if (recorder.recording) {
77
+ return true
78
+ }
79
+ }
80
+ return false
81
+ }
82
+
83
+ /**
84
+ * @internal
85
+ * Records how to revert a change for the change batch and the rollback
86
+ * recorders. It captures stored values before any listener can mutate them again. Restoring
87
+ * live values preserves identity and literal data, without snapshot processors
88
+ * or model defaults changing the state being restored.
89
+ */
90
+ export function recordChangeForRollback(change: IObjectDidChange | IArrayDidChange): void {
91
+ const batch = changeBatch?.recording ? changeBatch : undefined
92
+ if (!batch && !isAnyRollbackRecorderRecording()) return
93
+ if (
94
+ change.type === "splice" &&
95
+ change.addedCount === change.removedCount &&
96
+ change.added.every((value, index) => value === change.removed[index])
97
+ )
98
+ return
99
+
100
+ const target = change.object
101
+ if (batch?.validate && target !== batch.previousTarget) {
102
+ forEachTypedModelAncestor(target, (model) => batch.models.add(model))
103
+ batch.previousTarget = target
104
+ }
105
+
106
+ const revert = createRevert(change)
107
+ batch?.reverts.push(revert)
108
+ for (const recorder of rollbackRecorders) {
109
+ if (recorder.recording) {
110
+ recorder.reverts.push(revert)
111
+ }
112
+ }
113
+ }
114
+
115
+ function createRevert(change: IObjectDidChange | IArrayDidChange): () => void {
116
+ if (change.type === "splice") {
117
+ const { object, index, addedCount } = change
118
+ const removed = change.removed.slice()
119
+ return () => object.spliceWithArray(index, addedCount, removed)
120
+ }
121
+ if ("index" in change) {
122
+ const { object, index, oldValue } = change
123
+ return () => {
124
+ object[index] = oldValue
125
+ }
126
+ }
127
+ const { object } = change
128
+ const key = change.name as string
129
+ if (change.type === "add") {
130
+ return () => remove(object, key)
131
+ }
132
+ const { oldValue } = change
133
+ return () => set(object, key, oldValue)
134
+ }
135
+
136
+ /**
137
+ * Reverts the changes recorded since `start`, newest first. Rollback publishes
138
+ * compensating changes, just like snapshot reconciliation.
139
+ */
140
+ function rollBackBatch(batch: ChangeBatch, start: number): void {
141
+ batch.recording = false
142
+ try {
143
+ withoutTypeChecking(() => {
144
+ for (let i = batch.reverts.length - 1; i >= start; i--) {
145
+ try {
146
+ batch.reverts[i]()
147
+ } catch {
148
+ // Keep restoring after a rollback listener throws. The original
149
+ // failure takes precedence over errors from compensating changes.
150
+ }
151
+ }
152
+ })
153
+ } finally {
154
+ batch.reverts.length = start
155
+ batch.recording = true
156
+ }
157
+ }
158
+
159
+ /**
160
+ * @internal
161
+ * Applies the changes made by `fn` as a whole: when it throws, or when the
162
+ * changed models fail automatic type checking afterwards, the changes are
163
+ * rolled back before the error is rethrown. Errors thrown by the listeners of
164
+ * a change do not roll back, since the change itself was valid.
165
+ * A nested call only rolls back its own changes, while validation runs once
166
+ * the outermost call completes. Synchronous listener edits join the batch.
167
+ */
168
+ export function withChangeBatch(fn: () => void): void {
169
+ const outer = changeBatch
170
+ if (outer) {
171
+ if (!outer.recording) {
172
+ // (rolling back)
173
+ fn()
174
+ return
175
+ }
176
+ const start = outer.reverts.length
177
+ try {
178
+ fn()
179
+ } catch (error) {
180
+ if (error !== lastListenerError) {
181
+ rollBackBatch(outer, start)
182
+ }
183
+ throw error
184
+ }
185
+ return
186
+ }
187
+
188
+ const batch: ChangeBatch = {
189
+ validate: isTypeCheckingAfterChangeEnabled(),
190
+ models: new Set(),
191
+ reverts: [],
192
+ recording: true,
193
+ }
194
+ changeBatch = batch
195
+ lastListenerError = noListenerError
196
+ let validating = false
197
+ try {
198
+ if (batch.validate) {
199
+ withoutTypeChecking(fn)
200
+ // MobX has now invalidated dependencies, including added/removed keys.
201
+ validating = true
202
+ for (const model of batch.models) {
203
+ const error = model.typeCheck()
204
+ if (error) error.throw()
205
+ }
206
+ } else {
207
+ fn()
208
+ }
209
+ } catch (error) {
210
+ if (validating || error !== lastListenerError) {
211
+ rollBackBatch(batch, 0)
212
+ }
213
+ throw error
214
+ } finally {
215
+ changeBatch = undefined
216
+ lastListenerError = noListenerError
217
+ }
218
+ }
@@ -1,45 +1,56 @@
1
1
  import { runInAction } from "mobx"
2
2
  import { hasDataObjectParent } from "../parent/core"
3
3
  import { failure, isPrimitive } from "../utils"
4
- import { treeNodeMetadata } from "./treeNodeMetadata"
4
+ import { type TreeNodeMetadata, treeNodeMetadata } from "./treeNodeMetadata"
5
+ import { resyncTweakedChangeListeners } from "./tweakedChangeListeners"
5
6
 
6
- type Untweaker = (() => void) & {
7
- [secondaryUntweakerSymbol]?: () => void
8
- }
7
+ // untweaked objects that keep the MobX handlers they got when tweaked, which
8
+ // do nothing until they are tweaked again
9
+ const objectsWithIdleHandlers = new WeakSet<object>()
9
10
 
10
- const secondaryUntweakerSymbol = Symbol("secondaryUntweaker")
11
+ /**
12
+ * Activates the MobX handlers of a fully tweaked plain object or array, and
13
+ * returns whether they still have to be installed. Untweaking keeps them idle
14
+ * rather than disposing them, so no disposers are kept for every node. When
15
+ * idle handlers are reused, the tweaked change listeners are resynced, since
16
+ * they missed the changes made while the handlers were idle.
17
+ *
18
+ * @internal
19
+ */
20
+ export function activateHandlers(value: object): boolean {
21
+ treeNodeMetadata.get(value)!.handlersActive = true
22
+ if (!objectsWithIdleHandlers.delete(value)) {
23
+ return true
24
+ }
25
+ resyncTweakedChangeListeners(value)
26
+ return false
27
+ }
11
28
 
12
29
  /**
13
- * Stores two existing cleanup functions without allocating a third function to
14
- * close over them. The primary function is internal and never exposed.
30
+ * Makes the MobX handlers of an untweaked plain object or array idle.
15
31
  *
16
32
  * @internal
17
33
  */
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
34
+ export function deactivateHandlers(value: object, metadata: TreeNodeMetadata): void {
35
+ metadata.handlersActive = false
36
+ objectsWithIdleHandlers.add(value)
25
37
  }
26
38
 
27
39
  /**
40
+ * Whether the MobX handlers of a plain object or array are active.
41
+ *
28
42
  * @internal
29
43
  */
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
44
+ export function areHandlersActive(value: object): boolean {
45
+ return treeNodeMetadata.get(value)?.handlersActive === true
35
46
  }
36
47
 
37
48
  /**
38
49
  * @internal
39
50
  */
40
- export function runTweakedObjectUntweakers(untweaker: Untweaker): void {
41
- untweaker()
42
- untweaker[secondaryUntweakerSymbol]?.()
51
+ export function isTweakedObject(value: unknown, canBeDataObject: boolean): value is object {
52
+ const metadata = treeNodeMetadata.get(value as object)
53
+ return metadata?.tweaked === true && (canBeDataObject || metadata.dataObjectParent === undefined)
43
54
  }
44
55
 
45
56
  /**
@@ -5,11 +5,20 @@ import type { SnapshotData } from "../snapshot/internal"
5
5
  /** @internal */
6
6
  export interface TreeNodeMetadata {
7
7
  tweaked: boolean
8
- untweaker: (() => void) | undefined
8
+ /**
9
+ * Set once a plain object or array is fully tweaked, and unset when it is
10
+ * untweaked. Its MobX handlers do nothing while unset.
11
+ */
12
+ handlersActive: boolean
9
13
  parentPath: ParentPath<object> | undefined
10
14
  dataObjectParent: object | undefined
11
15
  objectChildren: ObjectChildrenData | undefined
12
16
  snapshot: SnapshotData | undefined
17
+ /**
18
+ * Increases every time the node is added to a parent's children, so it
19
+ * orders siblings like the parent's children set does.
20
+ */
21
+ attachOrder: number
13
22
  }
14
23
 
15
24
  /** @internal */
@@ -21,11 +30,12 @@ export function getOrCreateTreeNodeMetadata(value: object): TreeNodeMetadata {
21
30
  if (!metadata) {
22
31
  metadata = {
23
32
  tweaked: false,
24
- untweaker: undefined,
33
+ handlersActive: false,
25
34
  parentPath: undefined,
26
35
  dataObjectParent: undefined,
27
36
  objectChildren: undefined,
28
37
  snapshot: undefined,
38
+ attachOrder: 0,
29
39
  }
30
40
  treeNodeMetadata.set(value, metadata)
31
41
  }
@@ -1,14 +1,28 @@
1
- import { action } from "mobx"
1
+ import { action, isObservableArray, isObservableObject } from "mobx"
2
2
  import { isDataModel } from "../dataModel/utils"
3
+ import { Frozen } from "../frozen/Frozen"
3
4
  import { isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig"
5
+ import { getModelMetadata } from "../model/getModelMetadata"
6
+ import { isModel } from "../model/utils"
4
7
  import { getObjectChildren } from "../parent/coreObjectChildren"
5
8
  import { fastGetParent, type ParentPath } from "../parent/path"
6
9
  import { setParent } from "../parent/setParent"
10
+ import { fastIsRootStoreNoAtom } from "../rootStore/rootStore"
7
11
  import { unsetInternalSnapshot } from "../snapshot/internal"
8
12
  import type { AnyStandardType, TypeToData } from "../types/schemas"
9
13
  import { typeCheck } from "../types/typeCheck"
10
- import { failure, inDevMode, isMap, isObject, isPrimitive, isSet } from "../utils"
11
- import { isTweakedObject, runTweakedObjectUntweakers } from "./core"
14
+ import {
15
+ failure,
16
+ inDevMode,
17
+ isArray,
18
+ isMap,
19
+ isObject,
20
+ isPlainObject,
21
+ isPrimitive,
22
+ isSet,
23
+ } from "../utils"
24
+ import { getSafeErrorValuePreview } from "../utils/errorDiagnostics"
25
+ import { deactivateHandlers, isTweakedObject } from "./core"
12
26
  import { registerDefaultTweakers } from "./registerDefaultTweakers"
13
27
  import { treeNodeMetadata } from "./treeNodeMetadata"
14
28
 
@@ -99,9 +113,7 @@ function internalTweak<T>(value: T, parentPath: ParentPath<any> | undefined): T
99
113
 
100
114
  // unsupported (must go before plain object tweaker)
101
115
  if (isDataModel(value)) {
102
- throw failure(
103
- "data models are not directly supported. you may insert the data in the tree instead ('$' property)."
104
- )
116
+ throw dataModelsNotSupportedError()
105
117
  }
106
118
 
107
119
  registerDefaultTweakers()
@@ -115,18 +127,24 @@ function internalTweak<T>(value: T, parentPath: ParentPath<any> | undefined): T
115
127
  }
116
128
  }
117
129
 
118
- // unsupported
130
+ throw unsupportedValueError(value)
131
+ }
132
+
133
+ function dataModelsNotSupportedError() {
134
+ return failure(
135
+ "data models are not directly supported. you may insert the data in the tree instead ('$' property)."
136
+ )
137
+ }
138
+
139
+ function unsupportedValueError(value: unknown) {
119
140
  if (isMap(value)) {
120
- throw failure("maps are not directly supported. consider using 'ObjectMap' / 'asMap' instead.")
141
+ return failure("maps are not directly supported. consider using 'ObjectMap' / 'asMap' instead.")
121
142
  }
122
-
123
- // unsupported
124
143
  if (isSet(value)) {
125
- throw failure("sets are not directly supported. consider using 'ArraySet' / 'asSet' instead.")
144
+ return failure("sets are not directly supported. consider using 'ArraySet' / 'asSet' instead.")
126
145
  }
127
-
128
- throw failure(
129
- `tweak can only work over models, observable objects/arrays, or primitives, but got ${value} instead`
146
+ return failure(
147
+ `tweak can only work over models, observable objects/arrays, or primitives, but got ${getSafeErrorValuePreview(value)} instead`
130
148
  )
131
149
  }
132
150
 
@@ -140,6 +158,154 @@ export function tweak<T>(value: T, parentPath: ParentPath<any> | undefined): T {
140
158
  return isPrimitive(value) ? value : tweakNonPrimitive(value, parentPath)
141
159
  }
142
160
 
161
+ /**
162
+ * Tells whether a node that has a parent gets detached by the change being
163
+ * intercepted before the new values are attached.
164
+ * @internal
165
+ */
166
+ export type IsDetachedByChange = (node: object, parentPath: ParentPath<any>) => boolean
167
+
168
+ // nodes to be attached by the change being checked, since a node cannot be
169
+ // attached twice
170
+ const seenNodes = new Set<object>()
171
+
172
+ /**
173
+ * Throws the error `tweak` or `setParent` would throw when attaching `values`
174
+ * (and whatever they contain) after the change being intercepted detaches its
175
+ * values, without changing anything. Interceptors call it before detaching, so
176
+ * a rejected change leaves the tree as it was.
177
+ * `prepareValue`, when given, replaces each value before it is checked.
178
+ * @internal
179
+ */
180
+ export function assertCanAttach(
181
+ values: unknown[],
182
+ isDetachedByChange: IsDetachedByChange,
183
+ prepareValue?: (value: unknown) => unknown
184
+ ): void {
185
+ try {
186
+ for (let i = 0; i < values.length; i++) {
187
+ if (prepareValue) {
188
+ values[i] = prepareValue(values[i])
189
+ }
190
+ checkAttach(values[i], isDetachedByChange)
191
+ }
192
+ } finally {
193
+ seenNodes.clear()
194
+ }
195
+ }
196
+
197
+ /**
198
+ * `assertCanAttach` for a single value.
199
+ * @internal
200
+ */
201
+ export function assertCanAttachValue(value: unknown, isDetachedByChange: IsDetachedByChange): void {
202
+ if (isPrimitive(value)) {
203
+ return
204
+ }
205
+ try {
206
+ checkAttach(value, isDetachedByChange)
207
+ } finally {
208
+ seenNodes.clear()
209
+ }
210
+ }
211
+
212
+ function checkAttach(value: unknown, isDetachedByChange: IsDetachedByChange): void {
213
+ if (isPrimitive(value)) {
214
+ return
215
+ }
216
+
217
+ // (one metadata lookup instead of one per helper)
218
+ const metadata = treeNodeMetadata.get(value)
219
+ if (metadata?.tweaked) {
220
+ if (metadata.dataObjectParent !== undefined) {
221
+ throw failure("value must be the model object instance instead of the '$' sub-object")
222
+ }
223
+ if (fastIsRootStoreNoAtom(value)) {
224
+ throw failure("root stores cannot be attached to any parents")
225
+ }
226
+ if (isModel(value) && getModelMetadata(value).valueType) {
227
+ // setParent clones it when it has a parent
228
+ return
229
+ }
230
+ const parentPath = metadata.parentPath
231
+ if (
232
+ parentPath &&
233
+ !isDetachedByChange(value, parentPath) &&
234
+ !isUntweakedByChange(parentPath.parent, isDetachedByChange)
235
+ ) {
236
+ throw failure("an object cannot be assigned a new parent when it already has one")
237
+ }
238
+ assertNotSeen(value)
239
+ return
240
+ }
241
+
242
+ // the values internalTweak accepts, most common first
243
+ if (isPlainObject(value)) {
244
+ if (isObservableObject(value)) {
245
+ // (it becomes the tree node itself)
246
+ assertNotSeen(value)
247
+ }
248
+ checkAttachProps(value, isDetachedByChange)
249
+ return
250
+ }
251
+ if (isArray(value)) {
252
+ if (isObservableArray(value)) {
253
+ assertNotSeen(value)
254
+ }
255
+ for (let i = 0; i < value.length; i++) {
256
+ checkAttach(value[i], isDetachedByChange)
257
+ }
258
+ return
259
+ }
260
+ // (data models, frozen values and models can be observable objects too)
261
+ if (isDataModel(value)) {
262
+ throw dataModelsNotSupportedError()
263
+ }
264
+ if (value instanceof Frozen || isModel(value)) {
265
+ return
266
+ }
267
+ if (isObservableObject(value)) {
268
+ assertNotSeen(value)
269
+ checkAttachProps(value as Record<string, unknown>, isDetachedByChange)
270
+ return
271
+ }
272
+ throw unsupportedValueError(value)
273
+ }
274
+
275
+ function checkAttachProps(obj: Record<string, unknown>, isDetachedByChange: IsDetachedByChange) {
276
+ const keys = Object.keys(obj)
277
+ for (let i = 0; i < keys.length; i++) {
278
+ checkAttach(obj[keys[i]], isDetachedByChange)
279
+ }
280
+ }
281
+
282
+ function assertNotSeen(value: object) {
283
+ if (seenNodes.has(value)) {
284
+ throw failure("an object cannot be assigned a new parent when it already has one")
285
+ }
286
+ seenNodes.add(value)
287
+ }
288
+
289
+ /**
290
+ * Detaching a plain object or array untweaks it, which detaches its children.
291
+ */
292
+ function isUntweakedByChange(node: object, isDetachedByChange: IsDetachedByChange): boolean {
293
+ for (;;) {
294
+ const metadata = treeNodeMetadata.get(node)
295
+ if (!metadata?.handlersActive) {
296
+ return false
297
+ }
298
+ const parentPath = metadata.parentPath
299
+ if (!parentPath) {
300
+ return false
301
+ }
302
+ if (isDetachedByChange(node, parentPath)) {
303
+ return true
304
+ }
305
+ node = parentPath.parent
306
+ }
307
+ }
308
+
143
309
  /**
144
310
  * @internal
145
311
  */
@@ -155,8 +321,7 @@ export function tryUntweak(value: any): (() => void) | undefined {
155
321
  }
156
322
 
157
323
  const metadata = treeNodeMetadata.get(value)
158
- const untweaker = metadata?.untweaker
159
- if (!untweaker) {
324
+ if (!metadata?.handlersActive) {
160
325
  return undefined
161
326
  }
162
327
 
@@ -176,14 +341,13 @@ export function tryUntweak(value: any): (() => void) | undefined {
176
341
  }
177
342
 
178
343
  return () => {
179
- // post-untweaking, call the untweaker
180
- runTweakedObjectUntweakers(untweaker)
344
+ // post-untweaking
345
+ deactivateHandlers(value, metadata)
181
346
 
182
347
  // Match the former independent-map ordering: the node stops being tweaked
183
348
  // before its snapshot is unset, but the shared record must stay alive until
184
349
  // snapshot cleanup has consumed it.
185
350
  metadata.tweaked = false
186
- metadata.untweaker = undefined
187
351
  unsetInternalSnapshot(value)
188
352
  // These associations previously lived in independent WeakMaps and remain
189
353
  // valid across untweak/retweak, so keep their shared record when present.