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