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