mobx-keystone 2.0.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 (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/mobx-keystone.esm.js +2341 -780
  3. package/dist/mobx-keystone.esm.mjs +2341 -780
  4. package/dist/mobx-keystone.umd.js +2346 -782
  5. package/dist/types/actionMiddlewares/transactionMiddleware.d.ts +1 -1
  6. package/dist/types/actionMiddlewares/undoMiddleware.d.ts +17 -10
  7. package/dist/types/parent/arrayPaths.d.ts +1 -0
  8. package/dist/types/parent/treeOrder.d.ts +1 -0
  9. package/dist/types/patch/applyPatches.d.ts +2 -1
  10. package/dist/types/ref/modelRefIdIndex.d.ts +1 -0
  11. package/dist/types/ref/nodesById.d.ts +1 -0
  12. package/dist/types/ref/refIdIndex.d.ts +1 -0
  13. package/dist/types/snapshot/applySnapshot.d.ts +1 -0
  14. package/dist/types/snapshot/fromSnapshot.d.ts +0 -3
  15. package/dist/types/tweaker/changeRollback.d.ts +1 -0
  16. package/dist/types/tweaker/tweakedChangeListeners.d.ts +1 -0
  17. package/dist/types/utils/ModelPool.d.ts +7 -1
  18. package/dist/types/utils/chunks.d.ts +1 -0
  19. package/package.json +2 -2
  20. package/src/actionMiddlewares/transactionMiddleware.ts +40 -41
  21. package/src/actionMiddlewares/undoMiddleware.ts +47 -13
  22. package/src/model/BaseModel.ts +6 -4
  23. package/src/modelShared/modelDecorator.ts +5 -25
  24. package/src/parent/arrayPaths.ts +242 -0
  25. package/src/parent/coreObjectChildren.ts +300 -48
  26. package/src/parent/onChildAttachedTo.ts +130 -44
  27. package/src/parent/path.ts +10 -2
  28. package/src/parent/setParent.ts +27 -15
  29. package/src/parent/treeOrder.ts +53 -0
  30. package/src/parent/walkTree.ts +0 -73
  31. package/src/patch/applyPatches.ts +31 -26
  32. package/src/ref/core.ts +63 -27
  33. package/src/ref/modelRefIdIndex.ts +131 -0
  34. package/src/ref/nodesById.ts +60 -0
  35. package/src/ref/refIdIndex.ts +209 -0
  36. package/src/snapshot/applySnapshot.ts +26 -18
  37. package/src/snapshot/fromPlainObjectSnapshot.ts +3 -8
  38. package/src/snapshot/fromSnapshot.ts +2 -6
  39. package/src/snapshot/internal.ts +67 -10
  40. package/src/tweaker/changeRollback.ts +218 -0
  41. package/src/tweaker/core.ts +33 -20
  42. package/src/tweaker/treeNodeMetadata.ts +12 -2
  43. package/src/tweaker/tweak.ts +181 -18
  44. package/src/tweaker/tweakArray.ts +107 -27
  45. package/src/tweaker/tweakPlainObject.ts +56 -31
  46. package/src/tweaker/tweakedChangeListeners.ts +61 -0
  47. package/src/tweaker/typeChecking.ts +5 -104
  48. package/src/types/arrayBased/typesArray.ts +10 -11
  49. package/src/types/createCachedTypeCheck.ts +522 -94
  50. package/src/types/objectBased/typesRecord.ts +2 -5
  51. package/src/utils/ModelPool.ts +75 -19
  52. package/src/utils/chunks.ts +271 -0
  53. package/src/utils/index.ts +37 -0
@@ -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,
@@ -113,7 +118,10 @@ export const setNewInternalSnapshot = action(
113
118
  }
114
119
  )
115
120
 
116
- type MutateInternalSnapshotFn<T> = (prevSn: T) => void
121
+ /**
122
+ * Mutates a snapshot container in place and returns the values it wrote.
123
+ */
124
+ type MutateInternalSnapshotFn<T> = (prevSn: T) => readonly unknown[]
117
125
 
118
126
  function makeSnapshotMutable(sn: SnapshotData): any {
119
127
  let untransformed = sn.untransformed
@@ -127,7 +135,39 @@ function makeSnapshotMutable(sn: SnapshotData): any {
127
135
  return untransformed
128
136
  }
129
137
 
130
- function setSnapshotData(sn: SnapshotData, untransformed: any, freezeIfCloned = false): void {
138
+ function trackUnfrozenValues(
139
+ sn: SnapshotData,
140
+ untransformed: any,
141
+ wasFrozen: boolean,
142
+ writtenValues: readonly unknown[]
143
+ ): void {
144
+ // a cloned frozen container starts with only frozen values
145
+ let unfrozenValues = wasFrozen ? [] : sn.unfrozenValues
146
+ if (unfrozenValues === undefined) {
147
+ return
148
+ }
149
+ for (let i = 0; i < writtenValues.length; i++) {
150
+ const value = writtenValues[i]
151
+ if (!isPrimitive(value)) {
152
+ unfrozenValues.push(value)
153
+ }
154
+ }
155
+ const size = Array.isArray(untransformed)
156
+ ? untransformed.length
157
+ : Object.keys(untransformed).length
158
+ if (unfrozenValues.length > size) {
159
+ // cheaper to visit the whole container again
160
+ unfrozenValues = undefined
161
+ }
162
+ sn.unfrozenValues = unfrozenValues
163
+ }
164
+
165
+ function setSnapshotData(
166
+ sn: SnapshotData,
167
+ untransformed: any,
168
+ writtenValues: readonly unknown[],
169
+ freezeIfCloned = false
170
+ ): void {
131
171
  // `makeSnapshotMutable` returns a new container exactly when this snapshot
132
172
  // was frozen. All callers obtain `untransformed` through that helper.
133
173
  const wasFrozen = untransformed !== sn.untransformed
@@ -138,6 +178,11 @@ function setSnapshotData(sn: SnapshotData, untransformed: any, freezeIfCloned =
138
178
 
139
179
  const keepFrozen = freezeIfCloned && wasFrozen && !sn.transformFn
140
180
  sn.untransformedFrozen = keepFrozen
181
+ if (keepFrozen) {
182
+ sn.unfrozenValues = undefined
183
+ } else {
184
+ trackUnfrozenValues(sn, untransformed, wasFrozen, writtenValues)
185
+ }
141
186
  if (wasFrozen && !keepFrozen) {
142
187
  snapshotStates.set(untransformed, sn)
143
188
  }
@@ -152,8 +197,8 @@ function setSnapshotData(sn: SnapshotData, untransformed: any, freezeIfCloned =
152
197
 
153
198
  function updateOwnSnapshot<T>(sn: SnapshotData, mutate: MutateInternalSnapshotFn<T>): void {
154
199
  const untransformed = makeSnapshotMutable(sn)
155
- mutate(untransformed)
156
- setSnapshotData(sn, untransformed)
200
+ const writtenValues = mutate(untransformed)
201
+ setSnapshotData(sn, untransformed, writtenValues)
157
202
  sn.atom?.reportChanged()
158
203
  }
159
204
 
@@ -164,7 +209,7 @@ function updateParentSlot(parentSn: SnapshotData, path: PathElement, value: any)
164
209
  } else {
165
210
  untransformed[path] = value
166
211
  }
167
- setSnapshotData(parentSn, untransformed)
212
+ setSnapshotData(parentSn, untransformed, [value])
168
213
  parentSn.atom?.reportChanged()
169
214
  }
170
215
 
@@ -175,7 +220,8 @@ function flushDirtyChild(
175
220
  childSn: SnapshotData,
176
221
  childPath: PathElement,
177
222
  freezeAfterFlush: boolean,
178
- freezeClonedContainer: boolean
223
+ freezeClonedContainer: boolean,
224
+ writtenValues: unknown[]
179
225
  ): void {
180
226
  if (childSn.isUnset) {
181
227
  return
@@ -200,6 +246,7 @@ function flushDirtyChild(
200
246
  } else {
201
247
  untransformed[childPath] = childSn.transformed
202
248
  }
249
+ writtenValues.push(childSn.transformed)
203
250
  }
204
251
 
205
252
  function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: boolean): boolean {
@@ -218,6 +265,7 @@ function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: bo
218
265
  const untransformed = makeSnapshotMutable(sn)
219
266
  const freezeClonedContainer =
220
267
  freezeAfterFlush && untransformed !== sn.untransformed && !sn.transformFn
268
+ const writtenValues: unknown[] = []
221
269
 
222
270
  flushDirtyChild(
223
271
  node,
@@ -226,7 +274,8 @@ function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: bo
226
274
  dirtyChildSnapshot,
227
275
  dirtyChildPath,
228
276
  freezeAfterFlush,
229
- freezeClonedContainer
277
+ freezeClonedContainer,
278
+ writtenValues
230
279
  )
231
280
  additionalDirtyChildren?.forEach((dirtyChild, child) => {
232
281
  flushDirtyChild(
@@ -236,11 +285,12 @@ function flushDirtyChildren(node: object, sn: SnapshotData, freezeAfterFlush: bo
236
285
  dirtyChild.snapshot,
237
286
  dirtyChild.path,
238
287
  freezeAfterFlush,
239
- freezeClonedContainer
288
+ freezeClonedContainer,
289
+ writtenValues
240
290
  )
241
291
  })
242
292
 
243
- setSnapshotData(sn, untransformed, freezeClonedContainer)
293
+ setSnapshotData(sn, untransformed, writtenValues, freezeClonedContainer)
244
294
  return freezeClonedContainer
245
295
  }
246
296
 
@@ -383,7 +433,13 @@ export function freezeInternalSnapshot<T extends PrimitiveValue | object>(data:
383
433
  freezeInternalSnapshot(owner.untransformed)
384
434
  }
385
435
 
386
- if (Array.isArray(data)) {
436
+ const unfrozenValues =
437
+ owner !== undefined && owner.untransformed === data ? owner.unfrozenValues : undefined
438
+ if (unfrozenValues !== undefined) {
439
+ for (let i = 0; i < unfrozenValues.length; i++) {
440
+ freezeInternalSnapshot(unfrozenValues[i])
441
+ }
442
+ } else if (Array.isArray(data)) {
387
443
  for (let i = 0; i < data.length; i++) {
388
444
  freezeInternalSnapshot(data[i])
389
445
  }
@@ -398,6 +454,7 @@ export function freezeInternalSnapshot<T extends PrimitiveValue | object>(data:
398
454
 
399
455
  if (owner !== undefined && owner.untransformed === data) {
400
456
  owner.untransformedFrozen = true
457
+ owner.unfrozenValues = undefined
401
458
  }
402
459
 
403
460
  return data
@@ -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
  }