@jbrowse/mobx-state-tree 6.6.0 → 6.7.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## [6.7.0](https://github.com/GMOD/mobx-state-tree/compare/v6.6.1...v6.7.0) (2026-09-30)
2
+
3
+ ### Bug Fixes
4
+
5
+ - A dead node's never-read child reads as a dead instance ([a6c430e](https://github.com/GMOD/mobx-state-tree/commit/a6c430e926ec31fe226a10cafff6e2ea0add4012))
6
+ - Exempt only never-created dead nodes; ADR 0008 ([d15fb5b](https://github.com/GMOD/mobx-state-tree/commit/d15fb5b99399d7844af910db689cbb5c403ddbc3))
7
+ - A never-read node dies as if it had been read ([3ffffae](https://github.com/GMOD/mobx-state-tree/commit/3ffffae9f5f44e7c5ea1573364da2ce6224ff288))
8
+ - Fail clearly, before damaging the tree, on impossible attaches ([25e0dff](https://github.com/GMOD/mobx-state-tree/commit/25e0dffd31b02c57ec31195685e3551de7167bb0))
9
+ - Destroy children that hooks add while a node is built at death ([743f887](https://github.com/GMOD/mobx-state-tree/commit/743f887f0a749f5dff727c86429c5ffb7ce4b726))
10
+
11
+ ### Styling
12
+
13
+ - Format ADR 0008 ([4290662](https://github.com/GMOD/mobx-state-tree/commit/42906620c1c6fc58526251310468d0012f446159))
14
+
15
+ ## [6.6.1](https://github.com/GMOD/mobx-state-tree/compare/v6.6.0...v6.6.1) (2026-09-25)
16
+
17
+ ### Performance Improvements
18
+
19
+ - Reconcile children in linear time ([530e27f](https://github.com/GMOD/mobx-state-tree/commit/530e27f20046ba449d45c2724106fcb5b4133e06))
20
+
21
+ ### Tests
22
+
23
+ - Typecheck the reconcile tests; add pnpm typecheck to the verification norm ([a23e4f3](https://github.com/GMOD/mobx-state-tree/commit/a23e4f34f718dfd398b696ca5e36be88bbe52454))
24
+
1
25
  ## [6.6.0](https://github.com/GMOD/mobx-state-tree/compare/v6.5.2...v6.6.0) (2026-09-24)
2
26
 
3
27
  ### Bug Fixes
package/dist/index.d.ts CHANGED
@@ -624,6 +624,13 @@ declare class ObjectNode<C, S, T> extends BaseNode<C, S, T> {
624
624
  clearParent(): void;
625
625
  notifyDetachOf(subject: AnyObjectNode): void;
626
626
  private notifyChildrenOfDetach;
627
+ /**
628
+ * @internal
629
+ * @hidden
630
+ * A node that died inside a tree is its own root and has no identifier
631
+ * cache, so attaching it, or attaching anything to it, cannot work.
632
+ */
633
+ assertAttachable(): IdentifierCache;
627
634
  setParent(newParent: AnyObjectNode, subpath: string): void;
628
635
  protected fireHook(name: Hook): void;
629
636
  private _snapshotUponDeath?;
@@ -468,7 +468,7 @@ function resolveIdentifier(type, target, identifier) {
468
468
  assertIsType(type, 1);
469
469
  assertIsStateTreeNode(target, 2);
470
470
  assertIsValidIdentifier(identifier, 3);
471
- const node = getStateTreeNode(target).root.identifierCache.resolve(type, normalizeIdentifier(identifier));
471
+ const node = getStateTreeNode(target).root.identifierCache?.resolve(type, normalizeIdentifier(identifier));
472
472
  return node?.value;
473
473
  }
474
474
  /**
@@ -1211,7 +1211,7 @@ class ObjectNode extends BaseNode {
1211
1211
  // addNodeToCache no-ops without an identifier attribute, and reaching the
1212
1212
  // root to ask it is a walk up the whole parent chain
1213
1213
  if (this.identifierAttribute) {
1214
- const cache = parent ? parent.root.identifierCache : this.identifierCache;
1214
+ const cache = parent ? parent.assertAttachable() : this.identifierCache;
1215
1215
  cache.addNodeToCache(this);
1216
1216
  }
1217
1217
  }
@@ -1222,8 +1222,11 @@ class ObjectNode extends BaseNode {
1222
1222
  }
1223
1223
  }
1224
1224
  createObservableInstance(fireHooks = true) {
1225
+ const diedUncreated = this.state === NodeLifeCycle.DEAD &&
1226
+ this._observableInstanceState ===
1227
+ ObservableInstanceLifecycle.UNINITIALIZED;
1225
1228
  if (devMode()) {
1226
- if (this.state !== NodeLifeCycle.INITIALIZING) {
1229
+ if (!diedUncreated && this.state !== NodeLifeCycle.INITIALIZING) {
1227
1230
  // istanbul ignore next
1228
1231
  throw fail("assertion failed: the creation of the observable instance must be done on the initializing phase");
1229
1232
  }
@@ -1276,6 +1279,9 @@ class ObjectNode extends BaseNode {
1276
1279
  // subscribes to never pays for a full-tree snapshot at creation. keepAlive
1277
1280
  // on _snapshotComputed preserves snapshot referential stability without it.
1278
1281
  this._childNodes = EMPTY_OBJECT;
1282
+ if (diedUncreated) {
1283
+ return;
1284
+ }
1279
1285
  this.state = NodeLifeCycle.CREATED;
1280
1286
  if (fireHooks) {
1281
1287
  this.fireHook(Hook.afterCreate);
@@ -1330,6 +1336,19 @@ class ObjectNode extends BaseNode {
1330
1336
  child.notifyDetachOf(subject);
1331
1337
  }
1332
1338
  }
1339
+ /**
1340
+ * @internal
1341
+ * @hidden
1342
+ * A node that died inside a tree is its own root and has no identifier
1343
+ * cache, so attaching it, or attaching anything to it, cannot work.
1344
+ */
1345
+ assertAttachable() {
1346
+ const cache = this.root.identifierCache;
1347
+ if (!cache) {
1348
+ throw fail(`'${this}' is no longer part of a state tree, so it cannot be attached to one or have nodes attached to it.`);
1349
+ }
1350
+ return cache;
1351
+ }
1333
1352
  setParent(newParent, subpath) {
1334
1353
  const parentChanged = newParent !== this.parent;
1335
1354
  const subpathChanged = subpath !== this.subpath;
@@ -1358,9 +1377,9 @@ class ObjectNode extends BaseNode {
1358
1377
  }
1359
1378
  }
1360
1379
  if (parentChanged) {
1361
- // attach to new parent
1380
+ this.assertAttachable();
1362
1381
  this.environment = undefined; // will use root's
1363
- newParent.root.identifierCache.mergeCache(this);
1382
+ newParent.assertAttachable().mergeCache(this);
1364
1383
  this.baseSetParent(newParent, subpath);
1365
1384
  this.fireHook(Hook.afterAttach);
1366
1385
  }
@@ -1556,13 +1575,33 @@ class ObjectNode extends BaseNode {
1556
1575
  addHiddenFinalProp(this.storedValue, "toJSON", toJSON);
1557
1576
  }
1558
1577
  aboutToDie() {
1578
+ const wasUninitialized = this._observableInstanceState ===
1579
+ ObservableInstanceLifecycle.UNINITIALIZED;
1580
+ const children = this.getChildren();
1581
+ if (wasUninitialized &&
1582
+ (this.hasSnapshotPostProcessor ||
1583
+ children.some(child => child instanceof ObjectNode &&
1584
+ child._observableInstanceState !==
1585
+ ObservableInstanceLifecycle.UNINITIALIZED))) {
1586
+ this.createObservableInstanceIfNeeded();
1587
+ }
1588
+ for (const node of children) {
1589
+ if (node.isAlive) {
1590
+ node.aboutToDie();
1591
+ }
1592
+ }
1559
1593
  if (this._observableInstanceState ===
1560
1594
  ObservableInstanceLifecycle.UNINITIALIZED) {
1561
1595
  return;
1562
1596
  }
1563
- this.getChildren().forEach(node => {
1564
- node.aboutToDie();
1565
- });
1597
+ if (wasUninitialized) {
1598
+ const walked = new Set(children);
1599
+ for (const node of this.getChildren()) {
1600
+ if (node.isAlive && !walked.has(node)) {
1601
+ node.aboutToDie();
1602
+ }
1603
+ }
1604
+ }
1566
1605
  // beforeDestroy should run before the disposers since else we could end up in a situation where
1567
1606
  // a disposer added with addDisposer at this stage (beforeDestroy) is actually never released
1568
1607
  this.baseAboutToDie();
@@ -1859,12 +1898,16 @@ class ComplexType extends BaseType {
1859
1898
  current.setParent(parent, subpath);
1860
1899
  return current;
1861
1900
  }
1901
+ const newNode = getStateTreeNodeSafe(newValue);
1902
+ if (newNode) {
1903
+ newNode.assertAttachable();
1904
+ if (!current.isDetaching && newNode.isWithin(current)) {
1905
+ throw fail(`Cannot assign ${newNode} to '${parent.path}/${subpath}': it is part of ${current}, which it would replace.`);
1906
+ }
1907
+ }
1862
1908
  // current node cannot be recycled in any way
1863
1909
  current.die(); // noop if detaching
1864
- // attempt to reuse the new one
1865
- if (isStateTreeNode(newValue) && this.isAssignableFrom(getType(newValue))) {
1866
- // newValue is a Node as well, move it here..
1867
- const newNode = getStateTreeNode(newValue);
1910
+ if (newNode && this.isAssignableFrom(newNode.type)) {
1868
1911
  newNode.setParent(parent, subpath);
1869
1912
  return newNode;
1870
1913
  }
@@ -4469,98 +4512,174 @@ function array(subtype) {
4469
4512
  */
4470
4513
  function reconcileArrayChildren(parent, childType, oldNodes, newValues, firstNewPath) {
4471
4514
  let nothingChanged = true;
4472
- // When the element type is a plain model with an identifier, index the old
4473
- // nodes by id so a moved / replaced child is matched in O(1) instead of the
4474
- // linear scan in the reorder branch below. This turns a full array
4475
- // replacement (the common "load new data" case) from O(n^2) into O(n). Types
4476
- // whose id extraction needs type-specific preprocessing (union,
4477
- // snapshotProcessor, late, ...) are intentionally excluded: areSame must run
4478
- // `is()` before their id check, so they stay on the scan path.
4479
- // With at most one old node the scan below is already O(1), so building the
4480
- // index would only add a Map allocation to every single-element write.
4481
- let idIndex;
4482
- if (oldNodes.length > 1 &&
4483
- childType instanceof ModelType &&
4484
- childType.identifierAttribute) {
4485
- const byId = new Map();
4486
- for (const n of oldNodes) {
4487
- if (n instanceof ObjectNode && n.identifier !== null) {
4488
- byId.set(n.identifier, n);
4489
- }
4490
- }
4491
- idIndex = { attr: childType.identifierAttribute, byId };
4492
- }
4493
- for (let i = 0;; i++) {
4494
- const hasNewNode = i <= newValues.length - 1;
4495
- const oldNode = oldNodes[i];
4496
- let newValue = hasNewNode ? newValues[i] : undefined;
4515
+ const result = [];
4516
+ // every old node before `next` is claimed
4517
+ const claimed = new Uint8Array(oldNodes.length);
4518
+ let next = 0;
4519
+ let finder;
4520
+ for (let i = 0; i < newValues.length; i++) {
4521
+ while (claimed[next]) {
4522
+ next++;
4523
+ }
4524
+ const oldNode = oldNodes[next];
4525
+ let newValue = newValues[i];
4497
4526
  const newPath = `${firstNewPath + i}`;
4498
4527
  // for some reason, instead of newValue we got a node, fallback to the storedValue
4499
4528
  // TODO: https://github.com/mobxjs/mobx-state-tree/issues/340#issuecomment-325581681
4500
4529
  if (isNode(newValue)) {
4501
4530
  newValue = newValue.storedValue;
4502
4531
  }
4503
- if (!oldNode && !hasNewNode) {
4504
- // both are empty, end
4505
- break;
4532
+ if (!oldNode) {
4533
+ if (isStateTreeNode(newValue) &&
4534
+ getStateTreeNode(newValue).parent === parent) {
4535
+ // this node is owned by this parent, but not in the reconcilable set, so it must be double
4536
+ throw fail(`Cannot add an object to a state tree if it is already part of the same or another state tree. Tried to assign an object to '${parent.path}/${newPath}', but it lives already at '${getStateTreeNode(newValue).path}'`);
4537
+ }
4538
+ nothingChanged = false;
4539
+ result.push(valueAsNode(childType, parent, newPath, newValue));
4540
+ }
4541
+ else if (areSame(oldNode, newValue)) {
4542
+ claimed[next] = 1;
4543
+ result.push(valueAsNode(childType, parent, newPath, newValue, oldNode));
4506
4544
  }
4507
- else if (oldNode && !hasNewNode) {
4508
- // new one does not exists
4545
+ else {
4509
4546
  nothingChanged = false;
4510
- oldNodes.splice(i, 1);
4547
+ // a single-element write scans one node, cheaper than building an index
4548
+ const j = oldNodes.length > 1
4549
+ ? (finder ??= new ReuseFinder(childType, oldNodes, claimed)).find(newValue, next)
4550
+ : scanForSame(oldNodes, claimed, next, newValue);
4551
+ let oldMatch;
4552
+ if (j >= 0) {
4553
+ claimed[j] = 1;
4554
+ oldMatch = oldNodes[j];
4555
+ }
4556
+ result.push(valueAsNode(childType, parent, newPath, newValue, oldMatch));
4557
+ }
4558
+ }
4559
+ for (let j = next; j < oldNodes.length; j++) {
4560
+ if (!claimed[j]) {
4561
+ nothingChanged = false;
4562
+ const oldNode = oldNodes[j];
4511
4563
  if (oldNode instanceof ObjectNode) {
4512
4564
  // since it is going to be returned by pop/splice/shift better create it before killing it
4513
4565
  // so it doesn't end up in an undead state
4514
4566
  oldNode.createObservableInstanceIfNeeded();
4515
4567
  }
4516
4568
  oldNode.die();
4517
- i--;
4518
4569
  }
4519
- else if (!oldNode) {
4520
- // there is no old node, create it
4521
- // check if already belongs to the same parent. if so, avoid pushing item in. only swapping can occur.
4522
- if (isStateTreeNode(newValue) &&
4523
- getStateTreeNode(newValue).parent === parent) {
4524
- // this node is owned by this parent, but not in the reconcilable set, so it must be double
4525
- throw fail(`Cannot add an object to a state tree if it is already part of the same or another state tree. Tried to assign an object to '${parent.path}/${newPath}', but it lives already at '${getStateTreeNode(newValue).path}'`);
4526
- }
4527
- nothingChanged = false;
4528
- const newNode = valueAsNode(childType, parent, newPath, newValue);
4529
- oldNodes.splice(i, 0, newNode);
4570
+ }
4571
+ return nothingChanged ? null : result;
4572
+ }
4573
+ function scanForSame(oldNodes, claimed, from, value) {
4574
+ for (let j = from; j < oldNodes.length; j++) {
4575
+ if (!claimed[j] && areSame(oldNodes[j], value)) {
4576
+ return j;
4530
4577
  }
4531
- else if (areSame(oldNode, newValue)) {
4532
- // both are the same, reconcile
4533
- oldNodes[i] = valueAsNode(childType, parent, newPath, newValue, oldNode);
4578
+ }
4579
+ return -1;
4580
+ }
4581
+ /**
4582
+ * Picks the old node the reorder branch reuses: the first unclaimed one
4583
+ * `areSame` holds for. `areSame` reduces to an equality — node identity for a
4584
+ * live node, the old node's snapshot for anything else — except where the
4585
+ * value is a plain object and the old node carries an identifier, where it
4586
+ * runs `is()` before matching ids. So only that case scans, and an identified
4587
+ * plain model looks its id up instead, as ADR 0001 records.
4588
+ */
4589
+ class ReuseFinder {
4590
+ oldNodes;
4591
+ claimed;
4592
+ idAttribute;
4593
+ byId;
4594
+ byNode;
4595
+ bySnapshot;
4596
+ anyIdentified;
4597
+ constructor(childType, oldNodes, claimed) {
4598
+ this.oldNodes = oldNodes;
4599
+ this.claimed = claimed;
4600
+ this.idAttribute =
4601
+ childType instanceof ModelType ? childType.identifierAttribute : undefined;
4602
+ }
4603
+ find(value, next) {
4604
+ let j;
4605
+ if (this.idAttribute && isPlainObject(value)) {
4606
+ j =
4607
+ this.idIndex(next).get(normalizeIdentifier(value[this.idAttribute])) ??
4608
+ -1;
4609
+ }
4610
+ else if (isStateTreeNode(value)) {
4611
+ j = this.nodeIndex(next).get(getStateTreeNode(value)) ?? -1;
4612
+ }
4613
+ else if (!isPlainObject(value) || !this.hasIdentifiedNode(next)) {
4614
+ j = this.firstWithSnapshot(value, next);
4534
4615
  }
4535
4616
  else {
4536
- // nothing to do, try to reorder: find a candidate old node to reuse
4537
- let oldMatch = undefined;
4538
- if (idIndex && isPlainObject(newValue)) {
4539
- // for an identified element type a plain-object snapshot can only match
4540
- // by id (it is never a live node, and snapshot-reference equality still
4541
- // implies the same id), so a miss means "nothing to reuse" without
4542
- // scanning. areSame still verifies the single candidate.
4543
- const candidate = idIndex.byId.get(normalizeIdentifier(newValue[idIndex.attr]));
4544
- const j = candidate ? oldNodes.indexOf(candidate, i) : -1;
4545
- if (j >= i && areSame(oldNodes[j], newValue)) {
4546
- oldMatch = oldNodes.splice(j, 1)[0];
4617
+ return scanForSame(this.oldNodes, this.claimed, next, value);
4618
+ }
4619
+ return j >= 0 && !this.claimed[j] && areSame(this.oldNodes[j], value)
4620
+ ? j
4621
+ : -1;
4622
+ }
4623
+ // last one wins on a duplicate id, even when it is already claimed
4624
+ idIndex(next) {
4625
+ if (!this.byId) {
4626
+ this.byId = new Map();
4627
+ for (let j = next; j < this.oldNodes.length; j++) {
4628
+ const node = this.oldNodes[j];
4629
+ if (node instanceof ObjectNode && node.identifier !== null) {
4630
+ this.byId.set(node.identifier, j);
4547
4631
  }
4548
4632
  }
4549
- else {
4550
- // find a possible candidate to reuse
4551
- for (let j = i; j < oldNodes.length; j++) {
4552
- if (areSame(oldNodes[j], newValue)) {
4553
- oldMatch = oldNodes.splice(j, 1)[0];
4554
- break;
4555
- }
4633
+ }
4634
+ return this.byId;
4635
+ }
4636
+ nodeIndex(next) {
4637
+ if (!this.byNode) {
4638
+ this.byNode = new Map();
4639
+ for (let j = next; j < this.oldNodes.length; j++) {
4640
+ this.byNode.set(this.oldNodes[j], j);
4641
+ }
4642
+ }
4643
+ return this.byNode;
4644
+ }
4645
+ hasIdentifiedNode(next) {
4646
+ if (this.anyIdentified === undefined) {
4647
+ this.anyIdentified = this.oldNodes
4648
+ .slice(next)
4649
+ .some(node => node instanceof ObjectNode &&
4650
+ node.identifier !== null &&
4651
+ !!node.identifierAttribute);
4652
+ }
4653
+ return this.anyIdentified;
4654
+ }
4655
+ firstWithSnapshot(value, next) {
4656
+ if (!this.bySnapshot) {
4657
+ const first = new Map();
4658
+ const nextSame = new Int32Array(this.oldNodes.length);
4659
+ for (let j = this.oldNodes.length - 1; j >= next; j--) {
4660
+ const node = this.oldNodes[j];
4661
+ if (!this.claimed[j] && node.isAlive) {
4662
+ const snapshot = node.snapshot;
4663
+ nextSame[j] = first.get(snapshot) ?? -1;
4664
+ first.set(snapshot, j);
4556
4665
  }
4557
4666
  }
4558
- nothingChanged = false;
4559
- const newNode = valueAsNode(childType, parent, newPath, newValue, oldMatch);
4560
- oldNodes.splice(i, 0, newNode);
4667
+ this.bySnapshot = { first, nextSame };
4668
+ }
4669
+ const { first, nextSame } = this.bySnapshot;
4670
+ const head = first.get(value);
4671
+ if (head === undefined) {
4672
+ return -1;
4673
+ }
4674
+ let j = head;
4675
+ while (j >= 0 && (this.claimed[j] || !this.oldNodes[j].isAlive)) {
4676
+ j = nextSame[j];
4677
+ }
4678
+ if (j !== head) {
4679
+ first.set(value, j);
4561
4680
  }
4681
+ return j;
4562
4682
  }
4563
- return nothingChanged ? null : oldNodes;
4564
4683
  }
4565
4684
  /**
4566
4685
  * Convert a value to a node at given parent and subpath. Attempts to reuse old node if possible and given.