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
@@ -1,10 +1,105 @@
1
- import { computed, type IComputedValue, type IObjectDidChange, observe } from "mobx"
2
- import type { Path } from "../parent/pathTypes"
1
+ import {
2
+ computed,
3
+ createAtom,
4
+ type IArrayDidChange,
5
+ type IComputedValue,
6
+ type IObjectDidChange,
7
+ isObservableObject,
8
+ keys,
9
+ transaction,
10
+ untracked,
11
+ } from "mobx"
12
+ import { dataToModelNode, modelToDataNode } from "../parent/core"
13
+ import { fastGetParent, fastGetParentPath } from "../parent/path"
14
+ import type { Path, PathElement } from "../parent/pathTypes"
3
15
  import { isTweakedObject } from "../tweaker/core"
16
+ import {
17
+ addTweakedChangeListener,
18
+ type TweakedChangeListener,
19
+ } from "../tweaker/tweakedChangeListeners"
20
+ import { getMobxVersion } from "../utils"
21
+ import { ChunkedSequence, ChunkedSet, chunkSize, type SequenceChunk } from "../utils/chunks"
4
22
  import { TypeCheckError } from "./TypeCheckError"
5
23
 
24
+ /**
25
+ * Containers whose cached checks may be stale, each with its child on the way
26
+ * to the changed object (undefined for the changed object itself, and null
27
+ * when there are several).
28
+ */
29
+ let uncachedTypeCheckObjects: Map<object, object | undefined | null> | undefined
30
+
31
+ /**
32
+ * Context-free results of the uncached checks of those containers, by checker.
33
+ * Every typed ancestor of the changed object checks the path to it again, so
34
+ * without them a change would be checked in O(depth²).
35
+ */
36
+ let uncachedResults: Map<object, Map<object, TypeCheckError | null>> | undefined
37
+
38
+ /** @internal */
39
+ export function withoutCachedTypeChecking(obj: object, fn: () => void): void {
40
+ const previous = uncachedTypeCheckObjects
41
+ const affected = new Map(previous)
42
+ const add = (node: object, child: object | undefined) => {
43
+ affected.set(node, affected.has(node) && affected.get(node) !== child ? null : child)
44
+ }
45
+ // Only these containers can retain a cached result for the old shape.
46
+ // Model schema checks run on $ data, while refinements can check the model.
47
+ let child: object | undefined
48
+ let current: object | undefined = dataToModelNode(obj)
49
+ while (current) {
50
+ add(current, child)
51
+ const data: object = modelToDataNode(current)
52
+ if (data !== current) {
53
+ add(data, child)
54
+ }
55
+ child = current
56
+ current = fastGetParent(current, false)
57
+ }
58
+ const previousResults = uncachedResults
59
+ uncachedTypeCheckObjects = affected
60
+ uncachedResults = new Map()
61
+ try {
62
+ fn()
63
+ } finally {
64
+ uncachedTypeCheckObjects = previous
65
+ uncachedResults = previousResults
66
+ // the tree changed again, so the outer results may be stale
67
+ previousResults?.clear()
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Runs the uncached, context-free check of a container once per
73
+ * `withoutCachedTypeChecking` call, and contextualizes its result.
74
+ */
75
+ function checkUncached(
76
+ checker: object,
77
+ value: object,
78
+ path: Path,
79
+ typeCheckedValue: any,
80
+ check: () => TypeCheckError | null
81
+ ): TypeCheckError | null {
82
+ let results = uncachedResults!.get(checker)
83
+ if (!results) {
84
+ results = new Map()
85
+ uncachedResults!.set(checker, results)
86
+ }
87
+ let error = results.get(value)
88
+ if (error === undefined) {
89
+ error = check()
90
+ results.set(value, error)
91
+ }
92
+ return contextualizeCachedError(error, path, typeCheckedValue)
93
+ }
94
+
6
95
  const emptyPath: Path = []
7
96
 
97
+ /** Key or index of a child in its parent, or undefined when it is not its child. */
98
+ function getChildKey(parent: object, child: object): PathElement | undefined {
99
+ const parentPath = fastGetParentPath(child, false)
100
+ return parentPath?.parent === parent ? parentPath.path : undefined
101
+ }
102
+
8
103
  function contextualizeCachedError(
9
104
  error: TypeCheckError | null,
10
105
  path: Path,
@@ -16,6 +111,7 @@ function contextualizeCachedError(
16
111
 
17
112
  return new TypeCheckError({
18
113
  path: [...path, ...error.path],
114
+ modelTrail: error.modelTrail,
19
115
  expectedTypeName: error.expectedTypeName,
20
116
  actualValue: error.actualValue,
21
117
  typeCheckedValue,
@@ -24,11 +120,6 @@ function contextualizeCachedError(
24
120
 
25
121
  type CachedEntry = IComputedValue<TypeCheckError | null>
26
122
 
27
- interface EntryCache<K> {
28
- get(key: K): CachedEntry | undefined
29
- set(key: K, entry: CachedEntry): void
30
- }
31
-
32
123
  type IterateEntries<K> = (
33
124
  value: any,
34
125
  checkEntry: (entryKey: K) => TypeCheckError | null
@@ -41,118 +132,445 @@ type CheckEntry<K> = (
41
132
  typeCheckedValue: any
42
133
  ) => TypeCheckError | null
43
134
 
44
- function createAdaptivePerEntryCachedCheck<K>(
45
- iterateEntries: IterateEntries<K>,
46
- checkEntry: CheckEntry<K>,
47
- createEntryCache: (value: object) => EntryCache<K>
135
+ /**
136
+ * Creates one reactive computed for a complete container check.
137
+ *
138
+ * @internal
139
+ */
140
+ export function createWholeContainerCachedCheck(
141
+ check: (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null
48
142
  ): (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null {
49
- const aggregationCache = new WeakMap<object, IComputedValue<TypeCheckError | null>>()
50
-
51
- const checkAllEntries = (value: any, path: Path, typeCheckedValue: any) =>
52
- iterateEntries(value, (key) => checkEntry(value, key, path, typeCheckedValue))
143
+ const cache = new WeakMap<object, IComputedValue<TypeCheckError | null>>()
53
144
 
54
145
  return (value, path, typeCheckedValue) => {
55
146
  if (!isTweakedObject(value, true)) {
56
- return checkAllEntries(value, path, typeCheckedValue)
147
+ return check(value, path, typeCheckedValue)
148
+ }
149
+ if (uncachedTypeCheckObjects?.has(value)) {
150
+ return checkUncached(cache, value, path, typeCheckedValue, () =>
151
+ check(value, emptyPath, undefined)
152
+ )
57
153
  }
58
154
 
59
- let aggregation = aggregationCache.get(value)
60
- if (!aggregation) {
61
- let entries: EntryCache<K> | undefined
62
- let isFirstEvaluation = true
63
- const cachedCheckEntry = (key: K): TypeCheckError | null => {
64
- if (!entries) {
65
- entries = createEntryCache(value)
155
+ let cached = cache.get(value)
156
+ if (!cached) {
157
+ cached = computed(() => check(value, emptyPath, undefined), { keepAlive: true })
158
+ cache.set(value, cached)
159
+ }
160
+ const error = cached.get()
161
+ return contextualizeCachedError(error, path, typeCheckedValue)
162
+ }
163
+ }
164
+
165
+ // MobX 6+ batches while tracking a derivation. MobX < 6 does not, and computes
166
+ // unobserved computeds read outside a batch without caching them, which would
167
+ // check new chunks twice.
168
+ const needsBatchToCacheChunks = getMobxVersion() < 6
169
+
170
+ /**
171
+ * Checks the items of an array, starting at `firstIndex`, and returns the first
172
+ * error, or null.
173
+ *
174
+ * @internal
175
+ */
176
+ export type CheckArrayItems = (
177
+ items: ArrayLike<unknown>,
178
+ firstIndex: number,
179
+ path: Path,
180
+ typeCheckedValue: any
181
+ ) => TypeCheckError | null
182
+
183
+ class ArrayChunk {
184
+ items: unknown[]
185
+ /** Changed when the items change. */
186
+ readonly atom = createAtom("arrayTypeCheckChunk")
187
+ readonly check: IComputedValue<TypeCheckError | null>
188
+
189
+ constructor(items: unknown[], checkItems: CheckArrayItems) {
190
+ this.items = items
191
+ // error indexes are relative to the chunk, so moving it keeps its result
192
+ this.check = computed(() => {
193
+ this.atom.reportObserved()
194
+ return checkItems(this.items, 0, emptyPath, undefined)
195
+ })
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The items of an array split in chunks, kept up to date through the array
201
+ * changes.
202
+ */
203
+ class ArrayChunks
204
+ extends ChunkedSequence<unknown, ArrayChunk>
205
+ implements TweakedChangeListener<IArrayDidChange>
206
+ {
207
+ /** Changed when chunks are resized, since error indexes depend on their sizes. */
208
+ readonly atom = createAtom("arrayTypeCheckChunks")
209
+ private readonly array: readonly unknown[]
210
+ private readonly checkItems: CheckArrayItems
211
+
212
+ constructor(array: readonly unknown[], checkItems: CheckArrayItems) {
213
+ super()
214
+ this.array = array
215
+ this.checkItems = checkItems
216
+ this.appendChunks(untracked(() => array.slice()))
217
+ }
218
+
219
+ resync() {
220
+ this.reset(untracked(() => this.array.slice()))
221
+ this.atom.reportChanged()
222
+ }
223
+
224
+ /**
225
+ * Returns the first error. When given, the chunk with the item at
226
+ * `uncachedIndex` is checked without using its cached result.
227
+ */
228
+ check(uncachedIndex?: number): TypeCheckError | null {
229
+ let uncached = -1
230
+ if (uncachedIndex === undefined) {
231
+ this.atom.reportObserved()
232
+ } else {
233
+ uncached = this.locate(uncachedIndex)[0]
234
+ }
235
+ let offset = 0
236
+ for (let c = 0; c < this.chunks.length; c++) {
237
+ const chunk = this.chunks[c]
238
+ const error =
239
+ c === uncached ? this.checkItems(chunk.items, 0, emptyPath, undefined) : chunk.check.get()
240
+ if (error) {
241
+ return offset === 0 ? error : offsetErrorIndex(error, offset)
242
+ }
243
+ offset += chunk.items.length
244
+ }
245
+ return null
246
+ }
247
+
248
+ applyChange(change: IArrayDidChange) {
249
+ if (change.type === "update") {
250
+ const [c, offset] = this.locate(change.index)
251
+ const chunk = this.chunks[c]
252
+ chunk.items[offset] = change.newValue
253
+ chunk.atom.reportChanged()
254
+ } else {
255
+ this.splice(change.index, change.removedCount, change.added)
256
+ this.atom.reportChanged()
257
+ }
258
+ }
259
+
260
+ protected createChunk(items: unknown[]): ArrayChunk {
261
+ return new ArrayChunk(items, this.checkItems)
262
+ }
263
+
264
+ protected onSpliced(chunk: ArrayChunk): void {
265
+ chunk.atom.reportChanged()
266
+ }
267
+
268
+ protected onItemsMoved(chunk: ArrayChunk): void {
269
+ chunk.atom.reportChanged()
270
+ }
271
+ }
272
+
273
+ function offsetErrorIndex(error: TypeCheckError, offset: number): TypeCheckError {
274
+ return new TypeCheckError({
275
+ path: [(error.path[0] as number) + offset, ...error.path.slice(1)],
276
+ modelTrail: error.modelTrail,
277
+ expectedTypeName: error.expectedTypeName,
278
+ actualValue: error.actualValue,
279
+ typeCheckedValue: error.typeCheckedValue,
280
+ })
281
+ }
282
+
283
+ class RecordChunk implements SequenceChunk<string> {
284
+ items: string[]
285
+ /** Changed when its keys change. */
286
+ readonly atom = createAtom("recordTypeCheckChunk")
287
+ readonly check: IComputedValue<TypeCheckError | null>
288
+
289
+ constructor(
290
+ keys: string[],
291
+ checkKey: (key: string) => TypeCheckError | null,
292
+ getEntry: (key: string) => CachedEntry
293
+ ) {
294
+ this.items = keys
295
+ let isFirstEvaluation = true
296
+ const checkKeys = (): TypeCheckError | null => {
297
+ this.atom.reportObserved()
298
+ const keys = this.items
299
+ // the first check does not allocate per-entry state, and later ones only
300
+ // re-check the entries that changed
301
+ const check = isFirstEvaluation ? checkKey : (key: string) => getEntry(key).get()
302
+ isFirstEvaluation = false
303
+ for (let i = 0; i < keys.length; i++) {
304
+ const error = check(keys[i])
305
+ if (error) {
306
+ return error
66
307
  }
308
+ }
309
+ return null
310
+ }
311
+ // (the entries would not be cached otherwise when MobX < 6 checks whether
312
+ // this chunk changed outside a batch)
313
+ this.check = computed(needsBatchToCacheChunks ? () => transaction(checkKeys) : checkKeys)
314
+ }
315
+ }
67
316
 
68
- let entry = entries.get(key)
69
- if (!entry) {
70
- entry = computed(() => checkEntry(value, key, emptyPath, undefined))
71
- entries.set(key, entry)
317
+ /**
318
+ * The keys of a record split in chunks, kept up to date through the record
319
+ * changes.
320
+ */
321
+ class RecordChunks
322
+ extends ChunkedSet<string, RecordChunk>
323
+ implements TweakedChangeListener<IObjectDidChange>
324
+ {
325
+ /** Check of each entry, created once its chunk is checked again. */
326
+ private readonly entries = new Map<string, CachedEntry>()
327
+ /** Changed when chunks are added or removed. */
328
+ readonly atom = createAtom("recordTypeCheckChunks")
329
+ private readonly record: object
330
+ private readonly checkKey: (key: string) => TypeCheckError | null
331
+ private readonly getEntry = (key: string): CachedEntry => {
332
+ let entry = this.entries.get(key)
333
+ if (!entry) {
334
+ entry = computed(() => this.checkKey(key))
335
+ this.entries.set(key, entry)
336
+ }
337
+ return entry
338
+ }
339
+
340
+ constructor(record: object, checkKey: (key: string) => TypeCheckError | null) {
341
+ super()
342
+ this.record = record
343
+ this.checkKey = checkKey
344
+ this.appendChunks(untracked(() => keys(record) as string[]))
345
+ }
346
+
347
+ resync() {
348
+ this.entries.clear()
349
+ this.reset(untracked(() => keys(this.record) as string[]))
350
+ this.atom.reportChanged()
351
+ }
352
+
353
+ /**
354
+ * Whether any entry has an error. When given, the entry of `uncachedKey` is
355
+ * checked without using its cached result.
356
+ */
357
+ hasError(uncachedKey?: string): boolean {
358
+ let uncached: RecordChunk | undefined
359
+ if (uncachedKey === undefined) {
360
+ this.atom.reportObserved()
361
+ } else {
362
+ uncached = this.getChunkOf(uncachedKey)
363
+ }
364
+ for (const chunk of this.chunks) {
365
+ if (chunk !== uncached) {
366
+ if (chunk.check.get()) {
367
+ return true
72
368
  }
73
- return entry.get()
369
+ continue
74
370
  }
371
+ for (const k of chunk.items) {
372
+ if (k === uncachedKey ? this.checkKey(k) : this.getEntry(k).get()) {
373
+ return true
374
+ }
375
+ }
376
+ }
377
+ return false
378
+ }
75
379
 
76
- aggregation = computed(
77
- () => {
78
- if (isFirstEvaluation) {
79
- isFirstEvaluation = false
80
- return checkAllEntries(value, emptyPath, undefined)
81
- }
82
- return iterateEntries(value, cachedCheckEntry)
83
- },
84
- { keepAlive: true }
85
- )
86
- aggregationCache.set(value, aggregation)
380
+ applyChange(change: IObjectDidChange) {
381
+ const key = change.name
382
+ if (typeof key !== "string") {
383
+ return
384
+ }
385
+ // value updates invalidate the chunk reading them
386
+ if (change.type === "add") {
387
+ this.add(key)
388
+ } else if (change.type === "remove") {
389
+ this.entries.delete(key)
390
+ this.delete(key)
87
391
  }
392
+ }
88
393
 
89
- return contextualizeCachedError(aggregation.get(), path, typeCheckedValue)
394
+ protected createChunk(keys: string[]) {
395
+ return new RecordChunk(keys, this.checkKey, this.getEntry)
396
+ }
397
+
398
+ protected onChunkChanged(chunk: RecordChunk): void {
399
+ chunk.atom.reportChanged()
400
+ }
401
+
402
+ protected onChunksChanged(): void {
403
+ this.atom.reportChanged()
90
404
  }
91
405
  }
92
406
 
93
407
  /**
94
- * @internal
408
+ * Cached check for the values of a record.
95
409
  *
96
- * Creates an adaptive check for dynamic records.
410
+ * A single computed over every entry would make MobX revalidate every entry on
411
+ * each change, so the keys are split in chunks, each with its own computed, kept
412
+ * up to date from the record changes. A change then only revalidates one chunk
413
+ * and the chunk results. Once a chunk is invalidated each of its entries gets
414
+ * its own computed, so a value change only re-checks that value.
97
415
  *
98
- * For tweaked (observable) values:
99
- * 1. The first complete check is cached without allocating per-entry state.
100
- * 2. After the first invalidation, per-entry computeds each read one property, so
101
- * changing property A only invalidates property A's computed.
102
- * 3. The aggregation computed iterates per-entry computeds. Thanks to MobX's propagation,
103
- * if a per-entry computed re-evaluates but returns the same result (e.g. null → null),
104
- * the aggregation body is skipped entirely.
105
- * 4. A pruning observer is installed when the promoted entry cache is first used
106
- * and removes stale entries immediately when record keys are deleted.
416
+ * Chunks are not in the order the keys are iterated in, so when a chunk has an
417
+ * error the first error is found by checking every entry again in order.
107
418
  *
108
- * Cached checks produce context-free errors. A fresh error is contextualized
109
- * with the current path and checked root without executing user checkers again.
419
+ * The chunks learn about added and removed keys before the checks after that
420
+ * change run, and read values rather than keys, so unlike other cached checks
421
+ * this one stays valid while MobX has yet to invalidate the readers of the
422
+ * record keys (see `withoutCachedTypeChecking`). When the keys of an object
423
+ * below an entry change instead, only the check of that entry is stale, so
424
+ * only it is checked again without its cache.
110
425
  *
111
- * For non-tweaked values (plain snapshots), the entry iterator and checker run
112
- * directly with the caller's context.
426
+ * Cached checks produce context-free errors, which are contextualized with the
427
+ * current path and checked root without running the checks again. Values that
428
+ * are not tweaked (plain snapshots) are checked directly.
113
429
  *
114
- * @param iterateEntries Iterates the promoted per-entry computeds and returns
115
- * the first error, or null.
116
- * @param checkEntry Checks a single entry. The utility wraps it in a
117
- * non-keepAlive computed automatically after promotion.
430
+ * @internal
431
+ *
432
+ * @param iterateEntries Iterates the keys in order and returns the first error, or null.
433
+ * @param checkEntry Checks a single entry.
118
434
  */
119
- export function createAdaptiveRecordCachedCheck(
435
+ export function createChunkedRecordCachedCheck(
120
436
  iterateEntries: IterateEntries<PropertyKey>,
121
437
  checkEntry: CheckEntry<PropertyKey>
122
438
  ): (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null {
123
- return createAdaptivePerEntryCachedCheck(iterateEntries, checkEntry, (value) => {
124
- const entries = new Map<PropertyKey, CachedEntry>()
125
- observe(value, (change: IObjectDidChange) => {
126
- if (change.type === "remove") {
127
- entries.delete(change.name)
128
- }
129
- })
130
- return entries
131
- })
439
+ const cache = new WeakMap<
440
+ object,
441
+ { readonly chunks: RecordChunks; readonly check: IComputedValue<TypeCheckError | null> }
442
+ >()
443
+
444
+ const checkAllEntries = (value: any, path: Path, typeCheckedValue: any) =>
445
+ iterateEntries(value, (key) => checkEntry(value, key, path, typeCheckedValue))
446
+
447
+ return (value, path, typeCheckedValue) => {
448
+ // only tweaked plain objects notify the chunks of their key changes
449
+ if (!isTweakedObject(value, true) || !isObservableObject(value)) {
450
+ return checkAllEntries(value, path, typeCheckedValue)
451
+ }
452
+
453
+ let cached = cache.get(value)
454
+ const uncachedChild = uncachedTypeCheckObjects?.get(value)
455
+ if (uncachedChild !== undefined) {
456
+ // an entry (or something below it) has a stale cached check
457
+ const key = uncachedChild && getChildKey(value, uncachedChild)
458
+ const chunks = cached?.chunks
459
+ return checkUncached(cache, value, path, typeCheckedValue, () => {
460
+ if (chunks && typeof key === "string") {
461
+ const hasError = needsBatchToCacheChunks
462
+ ? transaction(() => chunks.hasError(key))
463
+ : chunks.hasError(key)
464
+ if (!hasError) {
465
+ return null
466
+ }
467
+ }
468
+ return checkAllEntries(value, emptyPath, undefined)
469
+ })
470
+ }
471
+
472
+ if (!cached) {
473
+ // keys already include a change being notified
474
+ const chunks = new RecordChunks(value, (key) => checkEntry(value, key, emptyPath, undefined))
475
+ addTweakedChangeListener<object>(value, chunks)
476
+ const check = computed(
477
+ () => {
478
+ const hasError = needsBatchToCacheChunks
479
+ ? transaction(() => chunks.hasError())
480
+ : chunks.hasError()
481
+ // any added or removed key changes a chunk, so this runs again then
482
+ return hasError ? checkAllEntries(value, emptyPath, undefined) : null
483
+ },
484
+ { keepAlive: true }
485
+ )
486
+ cached = { chunks, check }
487
+ cache.set(value, cached)
488
+ }
489
+
490
+ return contextualizeCachedError(cached.check.get(), path, typeCheckedValue)
491
+ }
132
492
  }
133
493
 
134
494
  /**
135
- * Creates one reactive computed for a complete container check.
495
+ * Cached check for the items of an array.
496
+ *
497
+ * MobX invalidates array reads at collection granularity, so one computed per
498
+ * item does not help. A single computed over all the items is O(n) on every
499
+ * change though: a push re-reads every item check, and a change inside one item
500
+ * makes MobX revalidate every item check the computed depends on.
501
+ *
502
+ * Instead, once an array grows large its items are split into chunks, each with
503
+ * its own computed over a plain copy of its items, which are kept up to date
504
+ * from the array changes rather than by reading the array. An item change
505
+ * revalidates one chunk, and a splice re-checks only the chunks it touched.
506
+ * Chunk errors use indexes relative to their chunk, so inserting items does not
507
+ * invalidate the chunks after it.
508
+ *
509
+ * When the keys of an object below an item change, only the chunk of that item
510
+ * is checked again without its cache (see `withoutCachedTypeChecking`).
136
511
  *
137
512
  * @internal
138
513
  */
139
- export function createWholeContainerCachedCheck(
140
- check: (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null
514
+ export function createChunkedArrayCachedCheck(
515
+ checkItems: CheckArrayItems
141
516
  ): (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null {
142
- const cache = new WeakMap<object, IComputedValue<TypeCheckError | null>>()
517
+ const cache = new WeakMap<
518
+ object,
519
+ { chunks: ArrayChunks | undefined; readonly check: IComputedValue<TypeCheckError | null> }
520
+ >()
143
521
 
144
522
  return (value, path, typeCheckedValue) => {
145
- if (!isTweakedObject(value, true)) {
146
- return check(value, path, typeCheckedValue)
523
+ const array: readonly unknown[] = value
524
+ if (!isTweakedObject(array, true)) {
525
+ return checkItems(array, 0, path, typeCheckedValue)
526
+ }
527
+
528
+ let cached = cache.get(array)
529
+ const uncachedChild = uncachedTypeCheckObjects?.get(array)
530
+ if (uncachedChild !== undefined) {
531
+ // an item (or something below it) has a stale cached check
532
+ const index = uncachedChild && getChildKey(array, uncachedChild)
533
+ const chunks = cached?.chunks
534
+ return checkUncached(cache, array, path, typeCheckedValue, () => {
535
+ if (!chunks || typeof index !== "number") {
536
+ return checkItems(array, 0, emptyPath, undefined)
537
+ }
538
+ return needsBatchToCacheChunks
539
+ ? transaction(() => chunks.check(index))
540
+ : chunks.check(index)
541
+ })
147
542
  }
148
543
 
149
- let cached = cache.get(value)
150
544
  if (!cached) {
151
- cached = computed(() => check(value, emptyPath, undefined), { keepAlive: true })
152
- cache.set(value, cached)
545
+ const state: {
546
+ chunks: ArrayChunks | undefined
547
+ readonly check: IComputedValue<TypeCheckError | null>
548
+ } = {
549
+ chunks: undefined,
550
+ check: computed(
551
+ () => {
552
+ if (!state.chunks) {
553
+ if (array.length <= chunkSize) {
554
+ return checkItems(array, 0, emptyPath, undefined)
555
+ }
556
+ // from now on the chunks follow the array changes, so the array
557
+ // is no longer read
558
+ const newChunks = new ArrayChunks(array, checkItems)
559
+ addTweakedChangeListener(array, newChunks)
560
+ state.chunks = newChunks
561
+ }
562
+
563
+ const chunks = state.chunks
564
+ return needsBatchToCacheChunks ? transaction(() => chunks.check()) : chunks.check()
565
+ },
566
+ { keepAlive: true }
567
+ ),
568
+ }
569
+ cached = state
570
+ cache.set(array, cached)
153
571
  }
154
- const error = cached.get()
155
- return contextualizeCachedError(error, path, typeCheckedValue)
572
+
573
+ return contextualizeCachedError(cached.check.get(), path, typeCheckedValue)
156
574
  }
157
575
  }
158
576
 
@@ -169,13 +587,48 @@ export function createIndexedPerEntryCachedCheck(
169
587
  iterateEntries: IterateEntries<number>,
170
588
  checkEntry: CheckEntry<number>
171
589
  ): (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null {
172
- return createAdaptivePerEntryCachedCheck(iterateEntries, checkEntry, () => {
173
- const entries: Array<CachedEntry | undefined> = []
174
- return {
175
- get: (index) => entries[index],
176
- set: (index, entry) => {
177
- entries[index] = entry
178
- },
590
+ const aggregationCache = new WeakMap<object, IComputedValue<TypeCheckError | null>>()
591
+
592
+ const checkAllEntries = (value: any, path: Path, typeCheckedValue: any) =>
593
+ iterateEntries(value, (key) => checkEntry(value, key, path, typeCheckedValue))
594
+
595
+ return (value, path, typeCheckedValue) => {
596
+ if (!isTweakedObject(value, true)) {
597
+ return checkAllEntries(value, path, typeCheckedValue)
179
598
  }
180
- })
599
+ if (uncachedTypeCheckObjects?.has(value)) {
600
+ return checkUncached(aggregationCache, value, path, typeCheckedValue, () =>
601
+ checkAllEntries(value, emptyPath, undefined)
602
+ )
603
+ }
604
+
605
+ let aggregation = aggregationCache.get(value)
606
+ if (!aggregation) {
607
+ let entries: Array<CachedEntry | undefined> | undefined
608
+ let isFirstEvaluation = true
609
+ const cachedCheckEntry = (index: number): TypeCheckError | null => {
610
+ entries ??= []
611
+ let entry = entries[index]
612
+ if (!entry) {
613
+ entry = computed(() => checkEntry(value, index, emptyPath, undefined))
614
+ entries[index] = entry
615
+ }
616
+ return entry.get()
617
+ }
618
+
619
+ aggregation = computed(
620
+ () => {
621
+ if (isFirstEvaluation) {
622
+ isFirstEvaluation = false
623
+ return checkAllEntries(value, emptyPath, undefined)
624
+ }
625
+ return iterateEntries(value, cachedCheckEntry)
626
+ },
627
+ { keepAlive: true }
628
+ )
629
+ aggregationCache.set(value, aggregation)
630
+ }
631
+
632
+ return contextualizeCachedError(aggregation.get(), path, typeCheckedValue)
633
+ }
181
634
  }