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
@@ -0,0 +1,242 @@
1
+ import { createAtom, type IAtom } from "mobx"
2
+ import { treeNodeMetadata } from "../tweaker/treeNodeMetadata"
3
+ import { isPrimitive } from "../utils"
4
+ import { ChunkedSequence, chunkSize, type SequenceChunk } from "../utils/chunks"
5
+ import { reportParentPathChanged } from "./core"
6
+ import type { ParentPath } from "./path"
7
+
8
+ /** Arrays whose splices would reindex more children than this switch to lazy indexes. */
9
+ const lazyIndexesThreshold = 1024
10
+
11
+ class PathChunk implements SequenceChunk<LazyArrayParentPath | undefined> {
12
+ /** Path of each item, or undefined for primitives. */
13
+ items: (LazyArrayParentPath | undefined)[]
14
+ offset = 0
15
+ /**
16
+ * Changed when the index of any of its items changes, or when items move
17
+ * out of it, so observers of their paths follow them to their new chunk.
18
+ */
19
+ readonly atom: IAtom = createAtom("arrayPathChunk")
20
+ readonly owner: ArrayPaths
21
+
22
+ constructor(owner: ArrayPaths, items: (LazyArrayParentPath | undefined)[]) {
23
+ this.owner = owner
24
+ this.items = items
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Parent path of an item of a large array. Its index is the offset of its chunk
30
+ * plus its position within it, so a splice only renumbers one chunk and moves
31
+ * the offsets of the chunks after it, instead of reindexing every item after it.
32
+ * Once removed, it keeps the index it had.
33
+ *
34
+ * @internal
35
+ */
36
+ export class LazyArrayParentPath implements ParentPath<object> {
37
+ // declared rather than defined fields: there are as many instances as array
38
+ // items, and defined fields compile to an Object.defineProperty call each
39
+ declare readonly parent: object
40
+ /** Undefined once removed from the array. */
41
+ declare chunk: PathChunk | undefined
42
+ /** Position within the chunk, or the final index once removed. */
43
+ declare local: number
44
+
45
+ constructor(parent: object, chunk: PathChunk, local: number) {
46
+ this.parent = parent
47
+ this.chunk = chunk
48
+ this.local = local
49
+ }
50
+
51
+ get path(): number {
52
+ const chunk = this.chunk
53
+ if (!chunk) {
54
+ return this.local
55
+ }
56
+ chunk.owner.updateOffsets()
57
+ return chunk.offset + this.local
58
+ }
59
+
60
+ /**
61
+ * An immutable copy for callers outside the library, which may keep it,
62
+ * reused while the index stays the same.
63
+ */
64
+ toParentPath(): ParentPath<object> {
65
+ const path = this.path
66
+ let publicPath = publicPaths.get(this)
67
+ if (publicPath?.path !== path) {
68
+ publicPath = { parent: this.parent, path }
69
+ publicPaths.set(this, publicPath)
70
+ }
71
+ return publicPath
72
+ }
73
+
74
+ /** Makes observers of this path also react to index changes. */
75
+ reportObserved(): void {
76
+ this.chunk?.atom.reportObserved()
77
+ }
78
+ }
79
+
80
+ // kept apart, since few paths are ever handed out
81
+ const publicPaths = new WeakMap<LazyArrayParentPath, ParentPath<object>>()
82
+
83
+ class ArrayPaths extends ChunkedSequence<LazyArrayParentPath | undefined, PathChunk> {
84
+ readonly array: readonly unknown[]
85
+ private offsetsDirty = false
86
+
87
+ constructor(array: readonly unknown[]) {
88
+ super()
89
+ this.array = array
90
+ const length = array.length
91
+ for (let start = 0; start < length; start += chunkSize) {
92
+ // a slice is much faster than indexed reads through an observable array
93
+ const items = array.slice(start, start + chunkSize)
94
+ const chunk = new PathChunk(this, new Array(items.length))
95
+ for (let i = 0; i < items.length; i++) {
96
+ const item = items[i]
97
+ if (!isPrimitive(item)) {
98
+ const entry = new LazyArrayParentPath(array, chunk, i)
99
+ chunk.items[i] = entry
100
+ treeNodeMetadata.get(item as object)!.parentPath = entry
101
+ // current observers of its path do not observe its chunk yet
102
+ reportParentPathChanged(item as object)
103
+ }
104
+ }
105
+ this.chunks.push(chunk)
106
+ }
107
+ this.offsetsDirty = true
108
+ }
109
+
110
+ updateOffsets(): void {
111
+ if (!this.offsetsDirty) {
112
+ return
113
+ }
114
+ this.offsetsDirty = false
115
+ let offset = 0
116
+ for (const chunk of this.chunks) {
117
+ chunk.offset = offset
118
+ offset += chunk.items.length
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Paths for values to be inserted at an index, undefined for primitives. They
124
+ * already resolve to their final index, but are only added by `splice`, so
125
+ * attaching the values can fail without leaving them behind.
126
+ */
127
+ createPaths(index: number, values: readonly unknown[]): (LazyArrayParentPath | undefined)[] {
128
+ this.updateOffsets()
129
+ if (this.chunks.length === 0) {
130
+ this.chunks.push(this.createChunk([]))
131
+ }
132
+ const [c, start] = this.locate(index)
133
+ const chunk = this.chunks[c]
134
+ const paths: (LazyArrayParentPath | undefined)[] = new Array(values.length)
135
+ for (let i = 0; i < values.length; i++) {
136
+ if (!isPrimitive(values[i])) {
137
+ paths[i] = new LazyArrayParentPath(this.array, chunk, start + i)
138
+ }
139
+ }
140
+ return paths
141
+ }
142
+
143
+ /** Applies a splice, freezing the removed paths and adding ones from `createPaths`. */
144
+ applySplice(
145
+ index: number,
146
+ removedCount: number,
147
+ addedPaths: (LazyArrayParentPath | undefined)[]
148
+ ): void {
149
+ this.updateOffsets()
150
+ this.splice(index, removedCount, addedPaths, freezeRemovedPath)
151
+ this.offsetsDirty = true
152
+ }
153
+
154
+ /** Replaces the path of an updated item, freezing the old one. */
155
+ set(index: number, value: unknown): LazyArrayParentPath | undefined {
156
+ this.updateOffsets()
157
+ const [c, local] = this.locate(index)
158
+ const chunk = this.chunks[c]
159
+ const old = chunk.items[local]
160
+ if (old) {
161
+ freezeRemovedPath(old, chunk, local)
162
+ }
163
+ const entry = isPrimitive(value) ? undefined : new LazyArrayParentPath(this.array, chunk, local)
164
+ chunk.items[local] = entry
165
+ return entry
166
+ }
167
+
168
+ protected createChunk(items: (LazyArrayParentPath | undefined)[]): PathChunk {
169
+ return new PathChunk(this, items)
170
+ }
171
+
172
+ protected onSpliced(chunk: PathChunk, from: number): void {
173
+ // the items after the splice moved within the chunk, and may move to other
174
+ // chunks when it is resized, where observers will follow them
175
+ renumber(chunk, from)
176
+ chunk.atom.reportChanged()
177
+ }
178
+
179
+ protected onItemsMoved(chunk: PathChunk, from: number): void {
180
+ renumber(chunk, from)
181
+ }
182
+
183
+ protected onShifted(chunk: PathChunk): void {
184
+ // the index of every item after the splice changed
185
+ chunk.atom.reportChanged()
186
+ }
187
+ }
188
+
189
+ function freezeRemovedPath(
190
+ entry: LazyArrayParentPath | undefined,
191
+ chunk: PathChunk,
192
+ local: number
193
+ ): void {
194
+ if (entry) {
195
+ entry.local = chunk.offset + local
196
+ entry.chunk = undefined
197
+ }
198
+ }
199
+
200
+ function renumber(chunk: PathChunk, from: number): void {
201
+ const items = chunk.items
202
+ for (let i = from; i < items.length; i++) {
203
+ const entry = items[i]
204
+ if (entry) {
205
+ entry.chunk = chunk
206
+ entry.local = i
207
+ }
208
+ }
209
+ }
210
+
211
+ const arrayPaths = new WeakMap<object, ArrayPaths>()
212
+
213
+ /**
214
+ * Lazy indexes of an array, created once a splice would reindex many of its
215
+ * items.
216
+ *
217
+ * @internal
218
+ */
219
+ export function getArrayPaths(
220
+ array: readonly unknown[],
221
+ reindexedCount: number
222
+ ): ArrayPaths | undefined {
223
+ let paths = arrayPaths.get(array)
224
+ if (!paths && reindexedCount > lazyIndexesThreshold) {
225
+ paths = new ArrayPaths(array)
226
+ arrayPaths.set(array, paths)
227
+ }
228
+ return paths
229
+ }
230
+
231
+ /**
232
+ * Makes an attached value use its lazy path, even if it was attached with an
233
+ * equal one.
234
+ *
235
+ * @internal
236
+ */
237
+ export function setLazyArrayParentPath(entry: LazyArrayParentPath, node: object): void {
238
+ const metadata = treeNodeMetadata.get(node)!
239
+ if (metadata.parentPath !== entry) {
240
+ metadata.parentPath = entry
241
+ }
242
+ }
@@ -1,12 +1,10 @@
1
- import { action, createAtom, type IAtom } from "mobx"
1
+ import { action, createAtom, type IAtom, untracked } from "mobx"
2
2
  import { addMutationBatchFinisher, isMutationBatchActive } from "../action/mutationBatch"
3
- import { getOrCreateTreeNodeMetadata } from "../tweaker/treeNodeMetadata"
3
+ import { getOrCreateTreeNodeMetadata, treeNodeMetadata } from "../tweaker/treeNodeMetadata"
4
4
  import { fastGetParent } from "./path"
5
5
 
6
6
  interface DeepObjectChildren {
7
7
  deep: Set<object>
8
-
9
- extensionsData: Map<object, unknown> | undefined
10
8
  }
11
9
 
12
10
  /** @internal */
@@ -16,6 +14,22 @@ export interface ObjectChildrenData extends DeepObjectChildren {
16
14
 
17
15
  deepDirty: boolean
18
16
  deepAtom: IAtom | undefined // will be created when first observed
17
+
18
+ extensions: Map<DeepObjectChildrenExtension<any>, ExtensionState<any>> | undefined
19
+
20
+ listeners: Set<ObjectChildrenListener> | undefined
21
+ }
22
+
23
+ /**
24
+ * Told about the nodes that may have been attached to or detached from the
25
+ * children of a node: its direct children, or all of its descendants when
26
+ * `deep`. Called inside the action making the change.
27
+ *
28
+ * @internal
29
+ */
30
+ export interface ObjectChildrenListener {
31
+ readonly deep: boolean
32
+ onChildrenChanged(nodes: readonly object[]): void
19
33
  }
20
34
 
21
35
  interface DeepChildrenInvalidationEntry {
@@ -64,6 +78,10 @@ function getOrCreateDeepChildrenMutationTransaction(): DeepChildrenMutationTrans
64
78
  return transaction
65
79
  }
66
80
 
81
+ // shared by all nodes whose deep children were never built (it is never mutated,
82
+ // but replaced when they are built), so each node does not allocate an empty set
83
+ const unbuiltDeepChildren = new Set<object>()
84
+
67
85
  function getObjectChildrenObject(node: object) {
68
86
  const metadata = getOrCreateTreeNodeMetadata(node)
69
87
  let obj = metadata.objectChildren
@@ -73,11 +91,13 @@ function getObjectChildrenObject(node: object) {
73
91
  shallow: new Set(),
74
92
  shallowAtom: undefined, // will be created when first observed
75
93
 
76
- deep: new Set(),
94
+ deep: unbuiltDeepChildren,
77
95
  deepDirty: true,
78
96
  deepAtom: undefined, // will be created when first observed
79
97
 
80
- extensionsData: initExtensionsData(),
98
+ extensions: undefined,
99
+
100
+ listeners: undefined,
81
101
  }
82
102
  metadata.objectChildren = obj
83
103
  }
@@ -97,6 +117,14 @@ export function getObjectChildren(node: object): ObjectChildrenData["shallow"] {
97
117
  return obj.shallow
98
118
  }
99
119
 
120
+ /**
121
+ * Like `getObjectChildren(node).size > 0`, but without observing or allocating anything.
122
+ * @internal
123
+ */
124
+ export function hasObjectChildren(node: object): boolean {
125
+ return (treeNodeMetadata.get(node)?.objectChildren?.shallow.size ?? 0) > 0
126
+ }
127
+
100
128
  /**
101
129
  * @internal
102
130
  */
@@ -107,19 +135,16 @@ export function getDeepObjectChildren(node: object): DeepObjectChildren {
107
135
  updateDeepObjectChildren(node)
108
136
  }
109
137
 
110
- if (!obj.deepAtom) {
111
- obj.deepAtom = createAtom("deepChildrenAtom")
112
- }
113
- obj.deepAtom.reportObserved()
138
+ reportDeepObserved(obj)
114
139
 
115
140
  return obj
116
141
  }
117
142
 
118
- function addNodeToDeepLists(node: any, data: DeepObjectChildren) {
119
- data.deep.add(node)
120
- data.extensionsData?.forEach((extensionData, dataSymbol) => {
121
- extensions.get(dataSymbol)!.addNode(node, extensionData)
122
- })
143
+ function reportDeepObserved(obj: ObjectChildrenData) {
144
+ if (!obj.deepAtom) {
145
+ obj.deepAtom = createAtom("deepChildrenAtom")
146
+ }
147
+ obj.deepAtom.reportObserved()
123
148
  }
124
149
 
125
150
  const updateDeepObjectChildren = action((node: object): DeepObjectChildren => {
@@ -147,19 +172,19 @@ function updateDeepObjectChildrenRecursive(node: object): DeepObjectChildren {
147
172
  }
148
173
  }
149
174
 
150
- obj.deep = new Set()
151
- obj.extensionsData = initExtensionsData(obj.extensionsData)
175
+ const deep = new Set<object>()
176
+ obj.deep = deep
152
177
 
153
178
  const childrenIterator = obj.shallow.values()
154
179
  let childrenIteratorResult = childrenIterator.next()
155
180
  while (!childrenIteratorResult.done) {
156
- addNodeToDeepLists(childrenIteratorResult.value, obj)
181
+ deep.add(childrenIteratorResult.value)
157
182
 
158
183
  const childDeepChildren = updateDeepObjectChildrenRecursive(childrenIteratorResult.value).deep
159
184
  const childDeepChildrenIterator = childDeepChildren.values()
160
185
  let childDeepChildrenIteratorResult = childDeepChildrenIterator.next()
161
186
  while (!childDeepChildrenIteratorResult.done) {
162
- addNodeToDeepLists(childDeepChildrenIteratorResult.value, obj)
187
+ deep.add(childDeepChildrenIteratorResult.value)
163
188
  childDeepChildrenIteratorResult = childDeepChildrenIterator.next()
164
189
  }
165
190
 
@@ -172,6 +197,8 @@ function updateDeepObjectChildrenRecursive(node: object): DeepObjectChildren {
172
197
  return obj
173
198
  }
174
199
 
200
+ let lastAttachOrder = 0
201
+
175
202
  /**
176
203
  * Must run inside the action-wrapped `setParent`, its only source caller.
177
204
  *
@@ -185,6 +212,7 @@ export function addObjectChild(node: object, child: object): void {
185
212
  if (shallow.size === previousShallowSize) {
186
213
  return
187
214
  }
215
+ getOrCreateTreeNodeMetadata(child).attachOrder = ++lastAttachOrder
188
216
 
189
217
  onShallowChildrenChanged(node, obj, child, true)
190
218
  }
@@ -218,7 +246,9 @@ function onShallowChildrenChanged(
218
246
  node,
219
247
  obj,
220
248
  transaction,
221
- recordDirectChildrenMutation(transaction, node, child, added)
249
+ recordDirectChildrenMutation(transaction, node, child, added),
250
+ child,
251
+ added
222
252
  )
223
253
  }
224
254
 
@@ -258,12 +288,35 @@ function invalidateDeepChildren(
258
288
  node: object,
259
289
  obj: ObjectChildrenData,
260
290
  transaction: DeepChildrenMutationTransaction | undefined,
261
- mutation: DirectChildrenMutationEntry | undefined
291
+ mutation: DirectChildrenMutationEntry | undefined,
292
+ child: object,
293
+ added: boolean
262
294
  ) {
263
295
  let currentNode: object | undefined = node
264
296
  let currentObj = obj
297
+ let extensionChange: ExtensionChange | undefined
298
+ let subtree: readonly object[] | undefined
265
299
 
266
300
  while (currentNode) {
301
+ if (currentObj.extensions) {
302
+ extensionChange ??= new ExtensionChange(
303
+ () => (subtree ??= collectSubtree(child)),
304
+ added,
305
+ false
306
+ )
307
+ extensionChange.applyTo(currentObj.extensions)
308
+ }
309
+
310
+ if (currentObj.listeners) {
311
+ for (const listener of currentObj.listeners) {
312
+ if (listener.deep) {
313
+ listener.onChildrenChanged((subtree ??= collectSubtree(child)))
314
+ } else if (currentNode === node) {
315
+ listener.onChildrenChanged([child])
316
+ }
317
+ }
318
+ }
319
+
267
320
  if (transaction) {
268
321
  let entry = transaction.invalidatedNodes?.get(currentNode)
269
322
  if (!entry && !currentObj.deepDirty) {
@@ -334,45 +387,244 @@ function finishDeepChildrenMutationTransaction(transaction: DeepChildrenMutation
334
387
  })
335
388
  }
336
389
 
337
- const extensions = new Map<object, DeepObjectChildrenExtension<any>>()
338
-
339
- interface DeepObjectChildrenExtension<D> {
390
+ /**
391
+ * Derived data kept per node about its deep children (e.g. an index of them).
392
+ * It is built from the deep children the first time it is needed and then kept
393
+ * up to date as descendants are attached and detached.
394
+ *
395
+ * @internal
396
+ */
397
+ export interface DeepObjectChildrenExtension<D> {
340
398
  initData(): D
341
- addNode(node: any, data: D): void
399
+ /** Adds a node while building the data from scratch, in deep children order. */
400
+ addNode(node: object, data: D): void
401
+ /**
402
+ * Adds a node attached after the data was built. Returns false when the data
403
+ * must be rebuilt instead. Defaults to `addNode`.
404
+ */
405
+ addNodeIncrementally?(node: object, data: D): boolean
406
+ /** Removes a detached node. Returns false when the data must be rebuilt instead. */
407
+ removeNode(node: object, data: D): boolean
408
+ /** Whether the data must be updated when a descendant model's id changes. */
409
+ dependsOnModelIds?: boolean
410
+ }
411
+
412
+ interface PendingExtensionChange {
413
+ readonly nodes: readonly object[]
414
+ readonly added: boolean
415
+ }
416
+
417
+ interface ExtensionState<D> {
418
+ readonly data: D
419
+ /** Data that could not be updated and must be rebuilt. */
420
+ dirty: boolean
421
+ /** Readers that need the data frozen; changes are queued meanwhile. */
422
+ pins: number
423
+ pending: PendingExtensionChange[] | undefined
342
424
  }
343
425
 
344
426
  /**
427
+ * Data of an extension frozen until released; changes made meanwhile are
428
+ * applied on release.
429
+ *
345
430
  * @internal
346
431
  */
347
- export function registerDeepObjectChildrenExtension<D>(extension: DeepObjectChildrenExtension<D>) {
348
- const dataSymbol = {}
349
- extensions.set(dataSymbol, extension)
350
-
351
- return (data: DeepObjectChildren): D => {
352
- let extensionsData = data.extensionsData
353
- let extensionData = extensionsData?.get(dataSymbol) as D | undefined
354
- if (!extensionsData?.has(dataSymbol)) {
355
- extensionsData = data.extensionsData ??= new Map()
356
- extensionData = extension.initData()
357
- extensionsData.set(dataSymbol, extensionData)
358
- data.deep.forEach((node) => {
359
- extension.addNode(node, extensionData!)
360
- })
432
+ export interface PinnedDeepObjectChildrenExtensionData<D> {
433
+ readonly data: D
434
+ release(): void
435
+ }
436
+
437
+ /**
438
+ * @internal
439
+ */
440
+ export interface DeepObjectChildrenExtensionAccessor<D> {
441
+ /**
442
+ * Gets the data. By default any change of the deep children is observed as a
443
+ * change of the data; pass `observeDeepChildren` false when the extension
444
+ * reports its own finer-grained changes.
445
+ */
446
+ get(node: object, observeDeepChildren?: boolean): D
447
+ pin(node: object): PinnedDeepObjectChildrenExtensionData<D>
448
+ }
449
+
450
+ /**
451
+ * @internal
452
+ */
453
+ export function registerDeepObjectChildrenExtension<D>(
454
+ extension: DeepObjectChildrenExtension<D>
455
+ ): DeepObjectChildrenExtensionAccessor<D> {
456
+ const getState = (node: object, observeDeepChildren: boolean): ExtensionState<D> => {
457
+ const obj = getObjectChildrenObject(node)
458
+ let state = obj.extensions?.get(extension) as ExtensionState<D> | undefined
459
+ // changes queued while pinned make the data stale for anybody else
460
+ if (!state || state.dirty || state.pending) {
461
+ // building reads the nodes, which must not become dependencies
462
+ state = untracked(() => buildExtensionState(extension, node))
361
463
  }
362
- return extensionData!
464
+ if (observeDeepChildren) {
465
+ reportDeepObserved(obj)
466
+ }
467
+ return state
468
+ }
469
+
470
+ return {
471
+ get(node, observeDeepChildren = true) {
472
+ return getState(node, observeDeepChildren).data
473
+ },
474
+
475
+ pin(node) {
476
+ const state = getState(node, false)
477
+ state.pins++
478
+ let released = false
479
+ return {
480
+ data: state.data,
481
+ release() {
482
+ if (released) return
483
+ released = true
484
+ state.pins--
485
+ if (state.pins === 0 && state.pending) {
486
+ const pending = state.pending
487
+ state.pending = undefined
488
+ for (const change of pending) {
489
+ applyExtensionChange(extension, state, change.nodes, change.added)
490
+ }
491
+ }
492
+ },
493
+ }
494
+ },
363
495
  }
364
496
  }
365
497
 
366
- function initExtensionsData(previousData?: ReadonlyMap<object, unknown>) {
367
- if (previousData === undefined || previousData.size === 0) {
368
- return undefined
498
+ function buildExtensionState<D>(
499
+ extension: DeepObjectChildrenExtension<D>,
500
+ node: object
501
+ ): ExtensionState<D> {
502
+ const data = extension.initData()
503
+ getDeepObjectChildren(node).deep.forEach((n) => {
504
+ extension.addNode(n, data)
505
+ })
506
+
507
+ const state: ExtensionState<D> = {
508
+ data,
509
+ dirty: false,
510
+ pins: 0,
511
+ pending: undefined,
369
512
  }
513
+ const obj = getObjectChildrenObject(node)
514
+ ;(obj.extensions ??= new Map()).set(extension, state)
515
+ return state
516
+ }
370
517
 
371
- const extensionsData = new Map<object, unknown>()
518
+ function applyExtensionChange(
519
+ extension: DeepObjectChildrenExtension<any>,
520
+ state: ExtensionState<any>,
521
+ nodes: readonly object[],
522
+ added: boolean
523
+ ) {
524
+ if (state.dirty) {
525
+ return
526
+ }
527
+ for (const n of nodes) {
528
+ let ok: boolean
529
+ if (!added) {
530
+ ok = extension.removeNode(n, state.data)
531
+ } else if (extension.addNodeIncrementally) {
532
+ ok = extension.addNodeIncrementally(n, state.data)
533
+ } else {
534
+ extension.addNode(n, state.data)
535
+ ok = true
536
+ }
537
+ if (!ok) {
538
+ state.dirty = true
539
+ return
540
+ }
541
+ }
542
+ }
372
543
 
373
- previousData?.forEach((_, dataSymbol) => {
374
- extensionsData.set(dataSymbol, extensions.get(dataSymbol)!.initData())
375
- })
544
+ /**
545
+ * A membership change of some deep children, applied to the extension data of
546
+ * each ancestor of the changed parent.
547
+ */
548
+ class ExtensionChange {
549
+ private nodes: readonly object[] | undefined
550
+ private readonly getNodes: () => readonly object[]
551
+ private readonly added: boolean
552
+ private readonly onlyModelIdDependent: boolean
553
+
554
+ constructor(getNodes: () => readonly object[], added: boolean, onlyModelIdDependent: boolean) {
555
+ this.getNodes = getNodes
556
+ this.added = added
557
+ this.onlyModelIdDependent = onlyModelIdDependent
558
+ }
376
559
 
377
- return extensionsData
560
+ applyTo(extensions: NonNullable<ObjectChildrenData["extensions"]>) {
561
+ extensions.forEach((state, extension) => {
562
+ if (state.dirty || (this.onlyModelIdDependent && !extension.dependsOnModelIds)) {
563
+ return
564
+ }
565
+ this.nodes ??= this.getNodes()
566
+ if (state.pins > 0) {
567
+ ;(state.pending ??= []).push({ nodes: this.nodes, added: this.added })
568
+ } else {
569
+ applyExtensionChange(extension, state, this.nodes, this.added)
570
+ }
571
+ })
572
+ }
573
+ }
574
+
575
+ /** The node and its descendants, parents first. */
576
+ function collectSubtree(root: object): object[] {
577
+ const nodes: object[] = []
578
+ const stack = [root]
579
+ while (stack.length > 0) {
580
+ const node = stack.pop()!
581
+ nodes.push(node)
582
+ const shallow = treeNodeMetadata.get(node)?.objectChildren?.shallow
583
+ shallow?.forEach((child) => {
584
+ stack.push(child)
585
+ })
586
+ }
587
+ return nodes
588
+ }
589
+
590
+ /**
591
+ * @internal
592
+ */
593
+ export function addObjectChildrenListener(node: object, listener: ObjectChildrenListener): void {
594
+ ;(getObjectChildrenObject(node).listeners ??= new Set()).add(listener)
595
+ }
596
+
597
+ /**
598
+ * @internal
599
+ */
600
+ export function removeObjectChildrenListener(node: object, listener: ObjectChildrenListener): void {
601
+ const obj = treeNodeMetadata.get(node)?.objectChildren
602
+ if (obj?.listeners?.delete(listener) && obj.listeners.size === 0) {
603
+ obj.listeners = undefined
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Must be called after the id of a model changes.
609
+ *
610
+ * @internal
611
+ */
612
+ export function onModelIdChanged(model: object): void {
613
+ const parent = fastGetParent(model, false)
614
+ if (!parent) {
615
+ return
616
+ }
617
+ // re-index the model under its new id
618
+ const nodes = [model]
619
+ const removal = new ExtensionChange(() => nodes, false, true)
620
+ const addition = new ExtensionChange(() => nodes, true, true)
621
+ let currentNode: object | undefined = parent
622
+ while (currentNode) {
623
+ const extensions = treeNodeMetadata.get(currentNode)?.objectChildren?.extensions
624
+ if (extensions) {
625
+ removal.applyTo(extensions)
626
+ addition.applyTo(extensions)
627
+ }
628
+ currentNode = fastGetParent(currentNode, false)
629
+ }
378
630
  }