mobx-keystone 2.0.0 → 2.2.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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/mobx-keystone.esm.js +2561 -1214
  4. package/dist/mobx-keystone.esm.mjs +2561 -1214
  5. package/dist/mobx-keystone.umd.js +2566 -1220
  6. package/dist/types/actionMiddlewares/transactionMiddleware.d.ts +1 -1
  7. package/dist/types/actionMiddlewares/undoMiddleware.d.ts +11 -12
  8. package/dist/types/modelShared/modelDecorator.d.ts +5 -2
  9. package/dist/types/modelShared/prop.d.ts +42 -29
  10. package/dist/types/parent/arrayPaths.d.ts +1 -0
  11. package/dist/types/parent/treeOrder.d.ts +1 -0
  12. package/dist/types/patch/applyPatches.d.ts +2 -1
  13. package/dist/types/ref/Ref.d.ts +1 -4
  14. package/dist/types/ref/modelRefIdIndex.d.ts +1 -0
  15. package/dist/types/ref/nodesById.d.ts +1 -0
  16. package/dist/types/ref/refIdIndex.d.ts +1 -0
  17. package/dist/types/snapshot/SnapshotOf.d.ts +4 -4
  18. package/dist/types/snapshot/applySnapshot.d.ts +2 -1
  19. package/dist/types/snapshot/fromSnapshot.d.ts +2 -5
  20. package/dist/types/snapshot/getSnapshot.d.ts +1 -1
  21. package/dist/types/tweaker/changeRollback.d.ts +1 -0
  22. package/dist/types/tweaker/tweakedChangeListeners.d.ts +1 -0
  23. package/dist/types/types/index.d.ts +1 -0
  24. package/dist/types/types/schemas.d.ts +41 -37
  25. package/dist/types/types/tProp.d.ts +12 -6
  26. package/dist/types/types/types.d.ts +6 -6
  27. package/dist/types/types/utility/typeToStoredData.d.ts +14 -10
  28. package/dist/types/types/utility/typesCodecCore.d.ts +3 -0
  29. package/dist/types/utils/ModelPool.d.ts +7 -2
  30. package/dist/types/utils/chunks.d.ts +1 -0
  31. package/dist/types/utils/types.d.ts +6 -0
  32. package/dist/types/wrappers/ArraySet.d.ts +1 -0
  33. package/package.json +5 -3
  34. package/src/action/modelFlow.ts +35 -66
  35. package/src/actionMiddlewares/actionTrackingMiddleware.ts +41 -71
  36. package/src/actionMiddlewares/transactionMiddleware.ts +41 -42
  37. package/src/actionMiddlewares/undoMiddleware.ts +63 -35
  38. package/src/context/context.ts +15 -36
  39. package/src/dataModel/utils.ts +5 -28
  40. package/src/deepChange/onDeepChange.ts +44 -126
  41. package/src/model/BaseModel.ts +7 -5
  42. package/src/model/utils.ts +5 -10
  43. package/src/modelShared/modelDecorator.ts +16 -30
  44. package/src/modelShared/prop.ts +115 -111
  45. package/src/modelShared/sharedInternalModel.ts +2 -2
  46. package/src/parent/arrayPaths.ts +239 -0
  47. package/src/parent/coreObjectChildren.ts +300 -48
  48. package/src/parent/onChildAttachedTo.ts +130 -44
  49. package/src/parent/path.ts +10 -12
  50. package/src/parent/setParent.ts +27 -15
  51. package/src/parent/treeOrder.ts +53 -0
  52. package/src/parent/walkTree.ts +0 -73
  53. package/src/patch/applyPatches.ts +31 -26
  54. package/src/patch/patchRecorder.ts +13 -28
  55. package/src/ref/core.ts +69 -46
  56. package/src/ref/modelRefIdIndex.ts +131 -0
  57. package/src/ref/nodesById.ts +60 -0
  58. package/src/ref/refIdIndex.ts +209 -0
  59. package/src/rootStore/rootStore.ts +1 -3
  60. package/src/snapshot/SnapshotOf.ts +24 -48
  61. package/src/snapshot/applySnapshot.ts +27 -19
  62. package/src/snapshot/fromPlainObjectSnapshot.ts +3 -8
  63. package/src/snapshot/fromSnapshot.ts +12 -9
  64. package/src/snapshot/getSnapshot.ts +1 -1
  65. package/src/snapshot/internal.ts +67 -12
  66. package/src/snapshot/reconcileModelSnapshot.ts +5 -3
  67. package/src/snapshot/reconcilePlainObjectSnapshot.ts +3 -1
  68. package/src/treeUtils/draft.ts +6 -1
  69. package/src/treeUtils/sandbox.ts +10 -7
  70. package/src/tweaker/changeRollback.ts +218 -0
  71. package/src/tweaker/core.ts +33 -20
  72. package/src/tweaker/treeNodeMetadata.ts +12 -2
  73. package/src/tweaker/tweak.ts +181 -18
  74. package/src/tweaker/tweakArray.ts +127 -77
  75. package/src/tweaker/tweakPlainObject.ts +65 -46
  76. package/src/tweaker/tweakedChangeListeners.ts +61 -0
  77. package/src/tweaker/typeChecking.ts +5 -104
  78. package/src/types/arrayBased/typesArray.ts +23 -30
  79. package/src/types/arrayBased/typesTuple.ts +16 -25
  80. package/src/types/createCachedTypeCheck.ts +509 -94
  81. package/src/types/index.ts +1 -0
  82. package/src/types/objectBased/typesDataModelData.ts +7 -8
  83. package/src/types/objectBased/typesObject.ts +11 -15
  84. package/src/types/objectBased/typesRecord.ts +12 -19
  85. package/src/types/schemas.ts +59 -71
  86. package/src/types/tProp.ts +29 -29
  87. package/src/types/utility/typeToStoredData.ts +22 -21
  88. package/src/types/utility/typesCodecCore.ts +5 -0
  89. package/src/types/utility/typesCodecSupport.ts +3 -3
  90. package/src/types/utility/typesMap.ts +43 -51
  91. package/src/types/utility/typesRefinement.ts +7 -8
  92. package/src/types/utility/typesSkipCheck.ts +7 -8
  93. package/src/types/utility/typesTag.ts +7 -8
  94. package/src/utils/ModelPool.ts +76 -37
  95. package/src/utils/chunks.ts +271 -0
  96. package/src/utils/decorators.ts +5 -5
  97. package/src/utils/index.ts +38 -20
  98. package/src/utils/types.ts +5 -0
  99. package/src/wrappers/ArraySet.ts +27 -14
  100. package/src/wrappers/ObjectMap.ts +1 -6
  101. package/src/wrappers/asMap.ts +1 -21
  102. package/src/wrappers/asSet.ts +0 -8
@@ -19,6 +19,10 @@ export interface SnapshotData {
19
19
  atom: IAtom | undefined
20
20
  // Source of truth for copy-on-write of this snapshot's untransformed data.
21
21
  untransformedFrozen: boolean
22
+ // When defined while `untransformed` is not frozen, every value in it except
23
+ // (possibly) these is already frozen, so freezing it only needs to visit them.
24
+ // Values listed here may no longer be in `untransformed`.
25
+ unfrozenValues: object[] | undefined
22
26
  dirtyChild: object | undefined
23
27
  dirtyChildSnapshot: SnapshotData | undefined
24
28
  dirtyChildPath: PathElement | undefined
@@ -94,6 +98,7 @@ export const setNewInternalSnapshot = action(
94
98
  transformed,
95
99
  atom: undefined, // will be created when first observed
96
100
  untransformedFrozen: markAsFrozen,
101
+ unfrozenValues: undefined,
97
102
  dirtyChild: undefined,
98
103
  dirtyChildSnapshot: undefined,
99
104
  dirtyChildPath: undefined,
@@ -108,12 +113,13 @@ export const setNewInternalSnapshot = action(
108
113
  }
109
114
 
110
115
  getOrCreateTreeNodeMetadata(value).snapshot = sn
111
-
112
- sn.atom?.reportChanged()
113
116
  }
114
117
  )
115
118
 
116
- type MutateInternalSnapshotFn<T> = (prevSn: T) => void
119
+ /**
120
+ * Mutates a snapshot container in place and returns the values it wrote.
121
+ */
122
+ type MutateInternalSnapshotFn<T> = (prevSn: T) => readonly unknown[]
117
123
 
118
124
  function makeSnapshotMutable(sn: SnapshotData): any {
119
125
  let untransformed = sn.untransformed
@@ -127,7 +133,39 @@ function makeSnapshotMutable(sn: SnapshotData): any {
127
133
  return untransformed
128
134
  }
129
135
 
130
- function setSnapshotData(sn: SnapshotData, untransformed: any, freezeIfCloned = false): void {
136
+ function trackUnfrozenValues(
137
+ sn: SnapshotData,
138
+ untransformed: any,
139
+ wasFrozen: boolean,
140
+ writtenValues: readonly unknown[]
141
+ ): void {
142
+ // a cloned frozen container starts with only frozen values
143
+ let unfrozenValues = wasFrozen ? [] : sn.unfrozenValues
144
+ if (unfrozenValues === undefined) {
145
+ return
146
+ }
147
+ for (let i = 0; i < writtenValues.length; i++) {
148
+ const value = writtenValues[i]
149
+ if (!isPrimitive(value)) {
150
+ unfrozenValues.push(value)
151
+ }
152
+ }
153
+ const size = Array.isArray(untransformed)
154
+ ? untransformed.length
155
+ : Object.keys(untransformed).length
156
+ if (unfrozenValues.length > size) {
157
+ // cheaper to visit the whole container again
158
+ unfrozenValues = undefined
159
+ }
160
+ sn.unfrozenValues = unfrozenValues
161
+ }
162
+
163
+ function setSnapshotData(
164
+ sn: SnapshotData,
165
+ untransformed: any,
166
+ writtenValues: readonly unknown[],
167
+ freezeIfCloned = false
168
+ ): void {
131
169
  // `makeSnapshotMutable` returns a new container exactly when this snapshot
132
170
  // was frozen. All callers obtain `untransformed` through that helper.
133
171
  const wasFrozen = untransformed !== sn.untransformed
@@ -138,6 +176,11 @@ function setSnapshotData(sn: SnapshotData, untransformed: any, freezeIfCloned =
138
176
 
139
177
  const keepFrozen = freezeIfCloned && wasFrozen && !sn.transformFn
140
178
  sn.untransformedFrozen = keepFrozen
179
+ if (keepFrozen) {
180
+ sn.unfrozenValues = undefined
181
+ } else {
182
+ trackUnfrozenValues(sn, untransformed, wasFrozen, writtenValues)
183
+ }
141
184
  if (wasFrozen && !keepFrozen) {
142
185
  snapshotStates.set(untransformed, sn)
143
186
  }
@@ -152,8 +195,8 @@ function setSnapshotData(sn: SnapshotData, untransformed: any, freezeIfCloned =
152
195
 
153
196
  function updateOwnSnapshot<T>(sn: SnapshotData, mutate: MutateInternalSnapshotFn<T>): void {
154
197
  const untransformed = makeSnapshotMutable(sn)
155
- mutate(untransformed)
156
- setSnapshotData(sn, untransformed)
198
+ const writtenValues = mutate(untransformed)
199
+ setSnapshotData(sn, untransformed, writtenValues)
157
200
  sn.atom?.reportChanged()
158
201
  }
159
202
 
@@ -164,7 +207,7 @@ function updateParentSlot(parentSn: SnapshotData, path: PathElement, value: any)
164
207
  } else {
165
208
  untransformed[path] = value
166
209
  }
167
- setSnapshotData(parentSn, untransformed)
210
+ setSnapshotData(parentSn, untransformed, [value])
168
211
  parentSn.atom?.reportChanged()
169
212
  }
170
213
 
@@ -175,7 +218,8 @@ function flushDirtyChild(
175
218
  childSn: SnapshotData,
176
219
  childPath: PathElement,
177
220
  freezeAfterFlush: boolean,
178
- freezeClonedContainer: boolean
221
+ freezeClonedContainer: boolean,
222
+ writtenValues: unknown[]
179
223
  ): void {
180
224
  if (childSn.isUnset) {
181
225
  return
@@ -200,6 +244,7 @@ function flushDirtyChild(
200
244
  } else {
201
245
  untransformed[childPath] = childSn.transformed
202
246
  }
247
+ writtenValues.push(childSn.transformed)
203
248
  }
204
249
 
205
250
  function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: boolean): boolean {
@@ -218,6 +263,7 @@ function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: bo
218
263
  const untransformed = makeSnapshotMutable(sn)
219
264
  const freezeClonedContainer =
220
265
  freezeAfterFlush && untransformed !== sn.untransformed && !sn.transformFn
266
+ const writtenValues: unknown[] = []
221
267
 
222
268
  flushDirtyChild(
223
269
  node,
@@ -226,7 +272,8 @@ function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: bo
226
272
  dirtyChildSnapshot,
227
273
  dirtyChildPath,
228
274
  freezeAfterFlush,
229
- freezeClonedContainer
275
+ freezeClonedContainer,
276
+ writtenValues
230
277
  )
231
278
  additionalDirtyChildren?.forEach((dirtyChild, child) => {
232
279
  flushDirtyChild(
@@ -236,11 +283,12 @@ function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: bo
236
283
  dirtyChild.snapshot,
237
284
  dirtyChild.path,
238
285
  freezeAfterFlush,
239
- freezeClonedContainer
286
+ freezeClonedContainer,
287
+ writtenValues
240
288
  )
241
289
  })
242
290
 
243
- setSnapshotData(sn, untransformed, freezeClonedContainer)
291
+ setSnapshotData(sn, untransformed, writtenValues, freezeClonedContainer)
244
292
  return freezeClonedContainer
245
293
  }
246
294
 
@@ -383,7 +431,13 @@ export function freezeInternalSnapshot<T extends PrimitiveValue | object>(data:
383
431
  freezeInternalSnapshot(owner.untransformed)
384
432
  }
385
433
 
386
- if (Array.isArray(data)) {
434
+ const unfrozenValues =
435
+ owner !== undefined && owner.untransformed === data ? owner.unfrozenValues : undefined
436
+ if (unfrozenValues !== undefined) {
437
+ for (let i = 0; i < unfrozenValues.length; i++) {
438
+ freezeInternalSnapshot(unfrozenValues[i])
439
+ }
440
+ } else if (Array.isArray(data)) {
387
441
  for (let i = 0; i < data.length; i++) {
388
442
  freezeInternalSnapshot(data[i])
389
443
  }
@@ -398,6 +452,7 @@ export function freezeInternalSnapshot<T extends PrimitiveValue | object>(data:
398
452
 
399
453
  if (owner !== undefined && owner.untransformed === data) {
400
454
  owner.untransformedFrozen = true
455
+ owner.unfrozenValues = undefined
401
456
  }
402
457
 
403
458
  return data
@@ -156,7 +156,9 @@ function reconcileModelSnapshot(
156
156
  if (!isReservedModelKey(k)) {
157
157
  const v = processedSn[k]
158
158
 
159
- const oldValue = data[k]
159
+ // an inherited member such as `toString` is no old value (but `__proto__`
160
+ // still reads the prototype, which is rejected as it is no tree node)
161
+ const oldValue = k === "__proto__" || hasOwnProp(data, k) ? data[k] : undefined
160
162
  // Non-null primitives need neither reconciliation nor detachment.
161
163
  // Nullish values must still pass through default handling below.
162
164
  if (v != null && isPrimitive(v)) {
@@ -216,10 +218,10 @@ function snapshotSlotsAreEqual(
216
218
  for (let i = 0; i < currentKeys.length; i++) {
217
219
  const key = currentKeys[i]
218
220
  // a matching key at the same index proves the key is an own property of
219
- // the incoming snapshot without an Object.hasOwn call; canonical snapshots
221
+ // the incoming snapshot without an own-property check; canonical snapshots
220
222
  // keep key order, so the fallback is rare (never trust inherited values,
221
223
  // e.g. a crafted __proto__ chain, without an own-property proof)
222
- if (incomingKeys[i] !== key && !Object.hasOwn(incomingSnapshot, key)) {
224
+ if (incomingKeys[i] !== key && !hasOwnProp(incomingSnapshot, key)) {
223
225
  return false
224
226
  }
225
227
  if (currentSnapshot[key] !== incomingSnapshot[key]) {
@@ -48,7 +48,9 @@ function reconcilePlainObjectSnapshot(
48
48
  const k = snKeys[i]
49
49
  const v = sn[k]
50
50
 
51
- const oldValue = plainObj[k]
51
+ // an inherited member such as `toString` is no old value (but `__proto__`
52
+ // still reads the prototype, which is rejected as it is no tree node)
53
+ const oldValue = k === "__proto__" || hasOwnProp(plainObj, k) ? plainObj[k] : undefined
52
54
  const newValue = withErrorPathSegment(k, () =>
53
55
  reconcileSnapshot(oldValue, v, modelPool, plainObj)
54
56
  )
@@ -6,7 +6,7 @@ import { applySnapshot } from "../snapshot/applySnapshot"
6
6
  import { fromSnapshot } from "../snapshot/fromSnapshot"
7
7
  import { getSnapshot } from "../snapshot/getSnapshot"
8
8
  import { assertTweakedObject } from "../tweaker/core"
9
- import { failure, mobxAction, mobxComputed } from "../utils"
9
+ import { failure, getMobxVersion, makeObservableCompat, mobxAction, mobxComputed } from "../utils"
10
10
  import { deepEquals } from "./deepEquals"
11
11
 
12
12
  /**
@@ -157,6 +157,11 @@ export class Draft<T extends object> {
157
157
  assertTweakedObject(original, "original")
158
158
 
159
159
  this.originalData = original
160
+
161
+ if (getMobxVersion() >= 6) {
162
+ makeObservableCompat(this)
163
+ }
164
+
160
165
  this.data = fromSnapshot(this.originalSnapshot, { generateNewIds: false })
161
166
  }
162
167
  }
@@ -233,9 +233,11 @@ export class SandboxManager {
233
233
  // see the reverted intermediate state.
234
234
  runInAction(() => {
235
235
  this.allowWrite(() => {
236
- for (let i = events.length - 1; i >= 0; i--) {
237
- applyPatches(this.subtreeRootClone, events[i].inversePatches, true)
238
- }
236
+ applyPatches(
237
+ this.subtreeRootClone,
238
+ events.map((event) => event.inversePatches),
239
+ true
240
+ )
239
241
  })
240
242
  applyPatches(
241
243
  this.subtreeRoot,
@@ -249,10 +251,11 @@ export class SandboxManager {
249
251
  const wasRecording = recorder.recording
250
252
  recorder.recording = false
251
253
  try {
252
- let i = recorder.events.length
253
- while (i-- > numRecorderEvents) {
254
- applyPatches(this.subtreeRootClone, recorder.events[i].inversePatches, true)
255
- }
254
+ applyPatches(
255
+ this.subtreeRootClone,
256
+ recorder.events.slice(numRecorderEvents).map((event) => event.inversePatches),
257
+ true
258
+ )
256
259
  recorder.events.splice(numRecorderEvents)
257
260
  } finally {
258
261
  recorder.recording = wasRecording
@@ -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,43 +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
- const metadata = treeNodeMetadata.get(value as object)
32
- return metadata?.tweaked === true && (canBeDataObject || metadata.dataObjectParent === undefined)
44
+ export function areHandlersActive(value: object): boolean {
45
+ return treeNodeMetadata.get(value)?.handlersActive === true
33
46
  }
34
47
 
35
48
  /**
36
49
  * @internal
37
50
  */
38
- export function runTweakedObjectUntweakers(untweaker: Untweaker): void {
39
- untweaker()
40
- 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)
41
54
  }
42
55
 
43
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
  }