@jbrowse/mobx-state-tree 6.6.0 → 6.6.1

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,13 @@
1
+ ## [6.6.1](https://github.com/GMOD/mobx-state-tree/compare/v6.6.0...v6.6.1) (2026-09-25)
2
+
3
+ ### Performance Improvements
4
+
5
+ - Reconcile children in linear time ([530e27f](https://github.com/GMOD/mobx-state-tree/commit/530e27f20046ba449d45c2724106fcb5b4133e06))
6
+
7
+ ### Tests
8
+
9
+ - Typecheck the reconcile tests; add pnpm typecheck to the verification norm ([a23e4f3](https://github.com/GMOD/mobx-state-tree/commit/a23e4f34f718dfd398b696ca5e36be88bbe52454))
10
+
1
11
  ## [6.6.0](https://github.com/GMOD/mobx-state-tree/compare/v6.5.2...v6.6.0) (2026-09-24)
2
12
 
3
13
  ### Bug Fixes
@@ -4469,98 +4469,174 @@ function array(subtype) {
4469
4469
  */
4470
4470
  function reconcileArrayChildren(parent, childType, oldNodes, newValues, firstNewPath) {
4471
4471
  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;
4472
+ const result = [];
4473
+ // every old node before `next` is claimed
4474
+ const claimed = new Uint8Array(oldNodes.length);
4475
+ let next = 0;
4476
+ let finder;
4477
+ for (let i = 0; i < newValues.length; i++) {
4478
+ while (claimed[next]) {
4479
+ next++;
4480
+ }
4481
+ const oldNode = oldNodes[next];
4482
+ let newValue = newValues[i];
4497
4483
  const newPath = `${firstNewPath + i}`;
4498
4484
  // for some reason, instead of newValue we got a node, fallback to the storedValue
4499
4485
  // TODO: https://github.com/mobxjs/mobx-state-tree/issues/340#issuecomment-325581681
4500
4486
  if (isNode(newValue)) {
4501
4487
  newValue = newValue.storedValue;
4502
4488
  }
4503
- if (!oldNode && !hasNewNode) {
4504
- // both are empty, end
4505
- break;
4489
+ if (!oldNode) {
4490
+ if (isStateTreeNode(newValue) &&
4491
+ getStateTreeNode(newValue).parent === parent) {
4492
+ // this node is owned by this parent, but not in the reconcilable set, so it must be double
4493
+ 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}'`);
4494
+ }
4495
+ nothingChanged = false;
4496
+ result.push(valueAsNode(childType, parent, newPath, newValue));
4497
+ }
4498
+ else if (areSame(oldNode, newValue)) {
4499
+ claimed[next] = 1;
4500
+ result.push(valueAsNode(childType, parent, newPath, newValue, oldNode));
4501
+ }
4502
+ else {
4503
+ nothingChanged = false;
4504
+ // a single-element write scans one node, cheaper than building an index
4505
+ const j = oldNodes.length > 1
4506
+ ? (finder ??= new ReuseFinder(childType, oldNodes, claimed)).find(newValue, next)
4507
+ : scanForSame(oldNodes, claimed, next, newValue);
4508
+ let oldMatch;
4509
+ if (j >= 0) {
4510
+ claimed[j] = 1;
4511
+ oldMatch = oldNodes[j];
4512
+ }
4513
+ result.push(valueAsNode(childType, parent, newPath, newValue, oldMatch));
4506
4514
  }
4507
- else if (oldNode && !hasNewNode) {
4508
- // new one does not exists
4515
+ }
4516
+ for (let j = next; j < oldNodes.length; j++) {
4517
+ if (!claimed[j]) {
4509
4518
  nothingChanged = false;
4510
- oldNodes.splice(i, 1);
4519
+ const oldNode = oldNodes[j];
4511
4520
  if (oldNode instanceof ObjectNode) {
4512
4521
  // since it is going to be returned by pop/splice/shift better create it before killing it
4513
4522
  // so it doesn't end up in an undead state
4514
4523
  oldNode.createObservableInstanceIfNeeded();
4515
4524
  }
4516
4525
  oldNode.die();
4517
- i--;
4518
4526
  }
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);
4527
+ }
4528
+ return nothingChanged ? null : result;
4529
+ }
4530
+ function scanForSame(oldNodes, claimed, from, value) {
4531
+ for (let j = from; j < oldNodes.length; j++) {
4532
+ if (!claimed[j] && areSame(oldNodes[j], value)) {
4533
+ return j;
4530
4534
  }
4531
- else if (areSame(oldNode, newValue)) {
4532
- // both are the same, reconcile
4533
- oldNodes[i] = valueAsNode(childType, parent, newPath, newValue, oldNode);
4535
+ }
4536
+ return -1;
4537
+ }
4538
+ /**
4539
+ * Picks the old node the reorder branch reuses: the first unclaimed one
4540
+ * `areSame` holds for. `areSame` reduces to an equality — node identity for a
4541
+ * live node, the old node's snapshot for anything else — except where the
4542
+ * value is a plain object and the old node carries an identifier, where it
4543
+ * runs `is()` before matching ids. So only that case scans, and an identified
4544
+ * plain model looks its id up instead, as ADR 0001 records.
4545
+ */
4546
+ class ReuseFinder {
4547
+ oldNodes;
4548
+ claimed;
4549
+ idAttribute;
4550
+ byId;
4551
+ byNode;
4552
+ bySnapshot;
4553
+ anyIdentified;
4554
+ constructor(childType, oldNodes, claimed) {
4555
+ this.oldNodes = oldNodes;
4556
+ this.claimed = claimed;
4557
+ this.idAttribute =
4558
+ childType instanceof ModelType ? childType.identifierAttribute : undefined;
4559
+ }
4560
+ find(value, next) {
4561
+ let j;
4562
+ if (this.idAttribute && isPlainObject(value)) {
4563
+ j =
4564
+ this.idIndex(next).get(normalizeIdentifier(value[this.idAttribute])) ??
4565
+ -1;
4566
+ }
4567
+ else if (isStateTreeNode(value)) {
4568
+ j = this.nodeIndex(next).get(getStateTreeNode(value)) ?? -1;
4569
+ }
4570
+ else if (!isPlainObject(value) || !this.hasIdentifiedNode(next)) {
4571
+ j = this.firstWithSnapshot(value, next);
4534
4572
  }
4535
4573
  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];
4574
+ return scanForSame(this.oldNodes, this.claimed, next, value);
4575
+ }
4576
+ return j >= 0 && !this.claimed[j] && areSame(this.oldNodes[j], value)
4577
+ ? j
4578
+ : -1;
4579
+ }
4580
+ // last one wins on a duplicate id, even when it is already claimed
4581
+ idIndex(next) {
4582
+ if (!this.byId) {
4583
+ this.byId = new Map();
4584
+ for (let j = next; j < this.oldNodes.length; j++) {
4585
+ const node = this.oldNodes[j];
4586
+ if (node instanceof ObjectNode && node.identifier !== null) {
4587
+ this.byId.set(node.identifier, j);
4547
4588
  }
4548
4589
  }
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
- }
4590
+ }
4591
+ return this.byId;
4592
+ }
4593
+ nodeIndex(next) {
4594
+ if (!this.byNode) {
4595
+ this.byNode = new Map();
4596
+ for (let j = next; j < this.oldNodes.length; j++) {
4597
+ this.byNode.set(this.oldNodes[j], j);
4598
+ }
4599
+ }
4600
+ return this.byNode;
4601
+ }
4602
+ hasIdentifiedNode(next) {
4603
+ if (this.anyIdentified === undefined) {
4604
+ this.anyIdentified = this.oldNodes
4605
+ .slice(next)
4606
+ .some(node => node instanceof ObjectNode &&
4607
+ node.identifier !== null &&
4608
+ !!node.identifierAttribute);
4609
+ }
4610
+ return this.anyIdentified;
4611
+ }
4612
+ firstWithSnapshot(value, next) {
4613
+ if (!this.bySnapshot) {
4614
+ const first = new Map();
4615
+ const nextSame = new Int32Array(this.oldNodes.length);
4616
+ for (let j = this.oldNodes.length - 1; j >= next; j--) {
4617
+ const node = this.oldNodes[j];
4618
+ if (!this.claimed[j] && node.isAlive) {
4619
+ const snapshot = node.snapshot;
4620
+ nextSame[j] = first.get(snapshot) ?? -1;
4621
+ first.set(snapshot, j);
4556
4622
  }
4557
4623
  }
4558
- nothingChanged = false;
4559
- const newNode = valueAsNode(childType, parent, newPath, newValue, oldMatch);
4560
- oldNodes.splice(i, 0, newNode);
4624
+ this.bySnapshot = { first, nextSame };
4625
+ }
4626
+ const { first, nextSame } = this.bySnapshot;
4627
+ const head = first.get(value);
4628
+ if (head === undefined) {
4629
+ return -1;
4630
+ }
4631
+ let j = head;
4632
+ while (j >= 0 && (this.claimed[j] || !this.oldNodes[j].isAlive)) {
4633
+ j = nextSame[j];
4634
+ }
4635
+ if (j !== head) {
4636
+ first.set(value, j);
4561
4637
  }
4638
+ return j;
4562
4639
  }
4563
- return nothingChanged ? null : oldNodes;
4564
4640
  }
4565
4641
  /**
4566
4642
  * Convert a value to a node at given parent and subpath. Attempts to reuse old node if possible and given.