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
@@ -1,34 +1,105 @@
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"
6
22
  import { TypeCheckError } from "./TypeCheckError"
7
23
 
8
- let uncachedTypeCheckObjects: Set<object> | undefined
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
9
37
 
10
38
  /** @internal */
11
39
  export function withoutCachedTypeChecking(obj: object, fn: () => void): void {
12
40
  const previous = uncachedTypeCheckObjects
13
- const affected = new Set(previous)
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
+ }
14
45
  // Only these containers can retain a cached result for the old shape.
15
46
  // Model schema checks run on $ data, while refinements can check the model.
47
+ let child: object | undefined
16
48
  let current: object | undefined = dataToModelNode(obj)
17
49
  while (current) {
18
- affected.add(current)
19
- affected.add(modelToDataNode(current))
50
+ add(current, child)
51
+ const data: object = modelToDataNode(current)
52
+ if (data !== current) {
53
+ add(data, child)
54
+ }
55
+ child = current
20
56
  current = fastGetParent(current, false)
21
57
  }
58
+ const previousResults = uncachedResults
22
59
  uncachedTypeCheckObjects = affected
60
+ uncachedResults = new Map()
23
61
  try {
24
62
  fn()
25
63
  } finally {
26
64
  uncachedTypeCheckObjects = previous
65
+ uncachedResults = previousResults
66
+ // the tree changed again, so the outer results may be stale
67
+ previousResults?.clear()
27
68
  }
28
69
  }
29
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
+
30
95
  const emptyPath: Path = []
31
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
+
32
103
  function contextualizeCachedError(
33
104
  error: TypeCheckError | null,
34
105
  path: Path,
@@ -49,11 +120,6 @@ function contextualizeCachedError(
49
120
 
50
121
  type CachedEntry = IComputedValue<TypeCheckError | null>
51
122
 
52
- interface EntryCache<K> {
53
- get(key: K): CachedEntry | undefined
54
- set(key: K, entry: CachedEntry): void
55
- }
56
-
57
123
  type IterateEntries<K> = (
58
124
  value: any,
59
125
  checkEntry: (entryKey: K) => TypeCheckError | null
@@ -66,118 +132,445 @@ type CheckEntry<K> = (
66
132
  typeCheckedValue: any
67
133
  ) => TypeCheckError | null
68
134
 
69
- function createAdaptivePerEntryCachedCheck<K>(
70
- iterateEntries: IterateEntries<K>,
71
- checkEntry: CheckEntry<K>,
72
- 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
73
142
  ): (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))
143
+ const cache = new WeakMap<object, IComputedValue<TypeCheckError | null>>()
78
144
 
79
145
  return (value, path, typeCheckedValue) => {
80
- if (uncachedTypeCheckObjects?.has(value) || !isTweakedObject(value, true)) {
81
- return checkAllEntries(value, path, typeCheckedValue)
146
+ if (!isTweakedObject(value, true)) {
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
+ )
82
153
  }
83
154
 
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)
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
91
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
+ }
92
316
 
93
- let entry = entries.get(key)
94
- if (!entry) {
95
- entry = computed(() => checkEntry(value, key, emptyPath, undefined))
96
- 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
97
368
  }
98
- return entry.get()
369
+ continue
99
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
+ }
100
379
 
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)
380
+ applyChange(change: IObjectDidChange) {
381
+ const key = change.name
382
+ if (typeof key !== "string") {
383
+ return
112
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)
391
+ }
392
+ }
113
393
 
114
- 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()
115
404
  }
116
405
  }
117
406
 
118
407
  /**
119
- * @internal
408
+ * Cached check for the values of a record.
120
409
  *
121
- * 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.
122
415
  *
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.
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.
132
418
  *
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.
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.
135
425
  *
136
- * For non-tweaked values (plain snapshots), the entry iterator and checker run
137
- * 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.
138
429
  *
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.
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.
143
434
  */
144
- export function createAdaptiveRecordCachedCheck(
435
+ export function createChunkedRecordCachedCheck(
145
436
  iterateEntries: IterateEntries<PropertyKey>,
146
437
  checkEntry: CheckEntry<PropertyKey>
147
438
  ): (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
- })
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
+ }
157
492
  }
158
493
 
159
494
  /**
160
- * 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`).
161
511
  *
162
512
  * @internal
163
513
  */
164
- export function createWholeContainerCachedCheck(
165
- check: (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null
514
+ export function createChunkedArrayCachedCheck(
515
+ checkItems: CheckArrayItems
166
516
  ): (value: any, path: Path, typeCheckedValue: any) => TypeCheckError | null {
167
- 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
+ >()
168
521
 
169
522
  return (value, path, typeCheckedValue) => {
170
- if (uncachedTypeCheckObjects?.has(value) || !isTweakedObject(value, true)) {
171
- 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
+ })
172
542
  }
173
543
 
174
- let cached = cache.get(value)
175
544
  if (!cached) {
176
- cached = computed(() => check(value, emptyPath, undefined), { keepAlive: true })
177
- 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)
178
571
  }
179
- const error = cached.get()
180
- return contextualizeCachedError(error, path, typeCheckedValue)
572
+
573
+ return contextualizeCachedError(cached.check.get(), path, typeCheckedValue)
181
574
  }
182
575
  }
183
576
 
@@ -194,13 +587,48 @@ export function createIndexedPerEntryCachedCheck(
194
587
  iterateEntries: IterateEntries<number>,
195
588
  checkEntry: CheckEntry<number>
196
589
  ): (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
- },
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)
204
598
  }
205
- })
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
+ }
206
634
  }