@colyseus/schema 5.0.14 → 5.0.19

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 (47) hide show
  1. package/README.md +7 -2
  2. package/build/Metadata.d.ts +10 -1
  3. package/build/codegen/api.d.ts +2 -0
  4. package/build/codegen/cli.cjs +322 -31
  5. package/build/codegen/cli.cjs.map +1 -1
  6. package/build/codegen/parser.d.ts +6 -1
  7. package/build/codegen/resolve.d.ts +25 -0
  8. package/build/codegen/types.d.ts +2 -0
  9. package/build/encoder/ChangeTree.d.ts +40 -12
  10. package/build/encoder/Encoder.d.ts +1 -1
  11. package/build/encoder/Root.d.ts +9 -0
  12. package/build/encoder/StateView.d.ts +38 -1
  13. package/build/encoder/changeTree/inheritedFlags.d.ts +13 -19
  14. package/build/encoder/changeTree/liveIteration.d.ts +8 -0
  15. package/build/encoder/changeTree/parentChain.d.ts +30 -8
  16. package/build/encoder/streaming.d.ts +1 -1
  17. package/build/index.cjs +3232 -2830
  18. package/build/index.cjs.map +1 -1
  19. package/build/index.js +3228 -2826
  20. package/build/index.mjs +3232 -2830
  21. package/build/index.mjs.map +1 -1
  22. package/build/types/TypeContext.d.ts +0 -17
  23. package/build/types/builder.d.ts +1 -5
  24. package/build/types/symbols.d.ts +1 -0
  25. package/package.json +1 -1
  26. package/src/Metadata.ts +59 -77
  27. package/src/Reflection.ts +9 -5
  28. package/src/annotations.ts +19 -13
  29. package/src/codegen/api.ts +3 -1
  30. package/src/codegen/cli.ts +5 -2
  31. package/src/codegen/parser.ts +69 -31
  32. package/src/codegen/resolve.ts +322 -0
  33. package/src/codegen/types.ts +4 -1
  34. package/src/decoder/DecodeOperation.ts +13 -2
  35. package/src/encoder/ChangeTree.ts +76 -25
  36. package/src/encoder/EncodeOperation.ts +10 -1
  37. package/src/encoder/Encoder.ts +52 -2
  38. package/src/encoder/Root.ts +28 -8
  39. package/src/encoder/StateView.ts +150 -66
  40. package/src/encoder/changeTree/inheritedFlags.ts +164 -45
  41. package/src/encoder/changeTree/liveIteration.ts +24 -3
  42. package/src/encoder/changeTree/parentChain.ts +72 -15
  43. package/src/encoder/streaming.ts +2 -1
  44. package/src/types/TypeContext.ts +5 -52
  45. package/src/types/builder.ts +14 -10
  46. package/src/types/custom/ArraySchema.ts +57 -14
  47. package/src/types/symbols.ts +3 -0
@@ -9,11 +9,12 @@ import type { Iterator } from "../encoding/decode.js";
9
9
  import { OPERATION, SWITCH_TO_STRUCTURE, TYPE_ID } from '../encoding/spec.js';
10
10
  import { Root } from "./Root.js";
11
11
 
12
- import type { StateView } from "./StateView.js";
12
+ import { ARRAY_SNAPSHOT, type StateView } from "./StateView.js";
13
13
  import type { ChangeTree, ChangeTreeList, ChangeTreeNode } from "./ChangeTree.js";
14
14
  import type { EncodeOperation } from "./EncodeOperation.js";
15
15
  import { forEachLiveWithCtx as _forEachLiveWithCtx } from "./changeTree/liveIteration.js";
16
16
  import { forEachChildWithCtx as _forEachChildWithCtx } from "./changeTree/treeAttachment.js";
17
+ import { drainFilterRefresh } from "./changeTree/inheritedFlags.js";
17
18
 
18
19
  /**
19
20
  * Reusable context passed to the recorder's forEachWithCtx to iterate changes
@@ -296,6 +297,10 @@ export class Encoder<T extends Schema = any> {
296
297
  initialOffset: number,
297
298
  unreliable: boolean,
298
299
  ): Uint8Array {
300
+ // Settle any pending per-edge filter re-derivations before routing
301
+ // fields to channels (see inheritedFlags.drainFilterRefresh).
302
+ if (this.root.pendingFilterRefresh.length > 0) drainFilterRefresh(this.root);
303
+
299
304
  const hasView = (view !== undefined);
300
305
  const rootChangeTree = this.state[$changes];
301
306
 
@@ -379,6 +384,9 @@ export class Encoder<T extends Schema = any> {
379
384
  view?: StateView,
380
385
  initialOffset: number = it.offset
381
386
  ): Uint8Array {
387
+ // Full-sync splits fields by the same isFiltered classification.
388
+ if (this.root.pendingFilterRefresh.length > 0) drainFilterRefresh(this.root);
389
+
382
390
  const hasView = (view !== undefined);
383
391
  const rootChangeTree = this.state[$changes];
384
392
 
@@ -521,7 +529,49 @@ export class Encoder<T extends Schema = any> {
521
529
 
522
530
  // Iterate entries directly — the inner Map gives us the (index, op)
523
531
  // pair without an intermediate keys array or Number() parse.
524
- for (const [index, op] of changes) {
532
+ for (const [key, op] of changes) {
533
+ // Element-binding entries under a collection parent are keyed
534
+ // by the child's ChangeTree — resolve its CURRENT wire slot
535
+ // here (a slot captured at view.add() time goes stale when
536
+ // the array reindexes later in the same tick).
537
+ let index: number;
538
+ if (key === ARRAY_SNAPSHOT) {
539
+ // Whole-array snapshot: emit an ADD per live element at
540
+ // its CURRENT slot. Structural walk at drain time — a
541
+ // reindex after view.add() cannot go stale, and staged
542
+ // holes (recorder DELETEs) are skipped by construction.
543
+ const tmpItems = refTarget.tmpItems;
544
+ const deletedIndexes = refTarget.deletedIndexes;
545
+ for (let slot = 0; slot < tmpItems.length; slot++) {
546
+ if (tmpItems[slot] === undefined || deletedIndexes[slot] === true) { continue; }
547
+ encoder(this, bytes, changeTree, slot, OPERATION.ADD, it, false, true, metadata);
548
+ }
549
+ continue;
550
+ }
551
+ if (typeof key === "number") {
552
+ index = key;
553
+ } else {
554
+ const resolved = key.indexInParent(ref);
555
+ if (resolved === undefined) { continue; } // detached and re-parented elsewhere
556
+ index = resolved;
557
+
558
+ // SAME PATCH view.add + state-removal: the element is
559
+ // leaving the array (recorder DELETE at its slot), so the
560
+ // binding is moot — and `$getByIndex` on a staged hole
561
+ // reads a DIFFERENT element (compacted `items`), which
562
+ // would ship a mismatched value payload. Cancel the
563
+ // binding AND the child's own pending entry (its refId
564
+ // was never introduced to this client). Topological
565
+ // drain order guarantees the child entry hasn't been
566
+ // visited yet. A recorder ADD at the slot needs no such
567
+ // guard — both channels emit ADD_BY_REFID and the
568
+ // decoder dedups by identity.
569
+ if (op === OPERATION.ADD && changeTree.getChange(index) === OPERATION.DELETE) {
570
+ view.changes.delete(key.ref[$refId]);
571
+ continue;
572
+ }
573
+ }
574
+
525
575
  // workaround when using view.add() on item that has been deleted from state
526
576
  // (see test "adding to view item that has been removed from state")
527
577
  const value = refTarget[$getByIndex](index);
@@ -1,6 +1,7 @@
1
1
  import { OPERATION } from "../encoding/spec.js";
2
2
  import { TypeContext } from "../types/TypeContext.js";
3
- import { ChangeTree, ChangeTreeList, createChangeTreeList, type ChangeTreeNode } from "./ChangeTree.js";
3
+ import { ChangeTree, ChangeTreeList, createChangeTreeList, PENDING_FILTER_REFRESH, type ChangeTreeNode } from "./ChangeTree.js";
4
+ import { restageLiveCb } from "./changeTree/liveIteration.js";
4
5
  import { $changes, $refId } from "../types/symbols.js";
5
6
  import type { StateView } from "./StateView.js";
6
7
  import type { StreamSchema } from "../types/custom/StreamSchema.js";
@@ -53,6 +54,22 @@ export class Root {
53
54
  */
54
55
  unreliableChanges: ChangeTreeList = createChangeTreeList();
55
56
 
57
+ /**
58
+ * Trees whose parent-edge set changed this tick (instance sharing
59
+ * gained or lost an edge). The encoder drains this before emission —
60
+ * `inheritedFlags.drainFilterRefresh` re-derives each tree's filter
61
+ * state against the then-settled containers. Only populated when the
62
+ * TypeContext has any @view/@stream field.
63
+ */
64
+ public pendingFilterRefresh: ChangeTree[] = [];
65
+
66
+ public enqueueFilterRefresh(tree: ChangeTree): void {
67
+ if (!this.types.hasFilters) return;
68
+ if (tree.flags & PENDING_FILTER_REFRESH) return;
69
+ tree.flags |= PENDING_FILTER_REFRESH;
70
+ this.pendingFilterRefresh.push(tree);
71
+ }
72
+
56
73
  /**
57
74
  * Free-list of ChangeTreeNode objects. Both queues share this pool —
58
75
  * a node carries no queue affinity, only `{ changeTree, prev, next, position }`.
@@ -172,17 +189,16 @@ export class Root {
172
189
  // values would otherwise never be encoded).
173
190
  //
174
191
  changeTree.needsRestage = false;
175
- changeTree.forEachLive((fieldIndex) => {
176
- if (changeTree.isFieldUnreliable(fieldIndex)) {
177
- changeTree.ensureUnreliableRecorder().record(fieldIndex, OPERATION.ADD);
178
- } else {
179
- changeTree.record(fieldIndex, OPERATION.ADD);
180
- }
181
- });
192
+ changeTree.forEachLiveWithCtx(changeTree, restageLiveCb);
182
193
  }
183
194
 
184
195
  this.refCount[refId] = (previousRefCount || 0) + 1;
185
196
 
197
+ // Gained a 2nd+ parent edge (instance sharing / re-assignment) —
198
+ // re-derive filter state before the next encode. Chokepoint for
199
+ // every attach path; mirrors the edge-loss enqueue in `remove()`.
200
+ if (previousRefCount > 0) this.enqueueFilterRefresh(changeTree);
201
+
186
202
  return isNewChangeTree;
187
203
  }
188
204
 
@@ -229,6 +245,10 @@ export class Root {
229
245
  } else {
230
246
  this.refCount[refId] = refCount;
231
247
 
248
+ // Lost one of several parent edges — the surviving edge set may
249
+ // no longer include a public path (or may have gained one).
250
+ this.enqueueFilterRefresh(changeTree);
251
+
232
252
  //
233
253
  // When losing a reference to an instance, it is best to move the
234
254
  // ChangeTree next to its parent in the encoding queue.
@@ -1,5 +1,5 @@
1
1
  import { ChangeTree, Ref } from "./ChangeTree.js";
2
- import { $changes, $fieldIndexesByViewTag, $refId, $viewFieldIndexes } from "../types/symbols.js";
2
+ import { $changes, $childType, $fieldIndexesByViewTag, $refId, $viewFieldIndexes } from "../types/symbols.js";
3
3
  import { DEFAULT_VIEW_TAG } from "../annotations.js";
4
4
  import { OPERATION } from "../encoding/spec.js";
5
5
  import { Metadata } from "../Metadata.js";
@@ -61,6 +61,16 @@ const _disposeRegistry = new FinalizationRegistry<{ root: Root; id: number; slot
61
61
  * populated collection inspects into dozens of lines of encoder
62
62
  * internals and buries the message that matters.
63
63
  */
64
+ /**
65
+ * Sentinel inner-map key: "snapshot every live element of this ref-typed
66
+ * ArraySchema". Written by `_add`'s bulk path instead of one entry per
67
+ * element; `encodeView` expands it structurally at drain time, so the
68
+ * emitted slots reflect any reindex that happened after `view.add()` —
69
+ * and a whole-array snapshot costs one Map insert instead of N.
70
+ * Real slots are never negative, so -1 cannot collide.
71
+ */
72
+ export const ARRAY_SNAPSHOT = -1;
73
+
64
74
  function describeArg(value: any): string {
65
75
  if (value === undefined) { return "undefined"; }
66
76
  if (value === null) { return "null"; }
@@ -107,8 +117,16 @@ export class StateView {
107
117
  * Inner storage is a Map so the encode loop in `encodeView` can iterate
108
118
  * directly with numeric keys — the legacy `{[index]: OPERATION}` shape
109
119
  * forced an `Object.keys(...)` allocation + `Number(key)` parse per ref.
120
+ *
121
+ * Inner keys are numbers (Schema field indexes, MapSchema journal
122
+ * indexes, Set/Collection indexes, stream positions — all stable within
123
+ * a tick), EXCEPT element bindings under a ref-typed ArraySchema parent,
124
+ * which are keyed by the child's ChangeTree. An array wire slot captured
125
+ * at `view.add()` time goes stale if the array reindexes (unshift /
126
+ * reverse / move) later in the same tick — identity keys let
127
+ * `encodeView` resolve the CURRENT slot at drain time instead.
110
128
  */
111
- changes = new Map<number, Map<number, OPERATION>>();
129
+ changes = new Map<number, Map<number | ChangeTree, OPERATION>>();
112
130
 
113
131
  constructor(public iterable: boolean = false) {
114
132
  if (iterable) {
@@ -368,15 +386,18 @@ export class StateView {
368
386
  // subclasses yield a real Metadata object.
369
387
  const metadata: Metadata = (obj.constructor as typeof Schema)[Symbol.metadata];
370
388
 
389
+ const wasVisible = this.isVisible(changeTree);
390
+
371
391
  // Add to iterable list (only the explicitly added items), deduping
372
- // re-adds of an already-visible instance. isVisible must be read
373
- // BEFORE markVisible; indexOf runs only on the re-add path.
374
- // NOTE: dedup applies to `items` only — a re-add still re-queues the
375
- // full snapshot on purpose (shared-view bootstrap re-add: a
376
- // late-attached client may not have consumed earlier drains).
377
- // Callers wanting cheap idempotence can guard with `view.has(obj)`.
392
+ // re-adds of an already-visible instance; indexOf runs only on the
393
+ // re-add path.
394
+ // NOTE: dedup applies to `items` only — a default-tag re-add still
395
+ // re-queues the full snapshot on purpose (shared-view bootstrap
396
+ // re-add: a late-attached client may not have consumed earlier
397
+ // drains). Callers wanting cheap idempotence can guard with
398
+ // `view.has(obj)`.
378
399
  if (this.iterable && checkIncludeParent
379
- && (!this.isVisible(changeTree) || this.items.indexOf(obj) === -1)) {
400
+ && (!wasVisible || this.items.indexOf(obj) === -1)) {
380
401
  this.items.push(obj);
381
402
  }
382
403
 
@@ -469,32 +490,51 @@ export class StateView {
469
490
  });
470
491
  }
471
492
  }
493
+ }
472
494
 
473
- } else if (!changeTree.isNew || isChildAdded) {
474
- // new structures will be added as part of .encode() call, no need to force it to .encodeView()
475
-
476
- // Full-sync snapshot: walk the live ref structurally instead of
477
- // iterating a cumulative recorder bucket. Every populated index
478
- // is emitted as ADD (matching the op-coercion previously done
479
- // at encode time). Per-field tags come from the descriptor's
480
- // precomputed `tags[]` array — direct index vs a metadata[i].tag
481
- // object hop.
482
- //
483
- // Non-matching custom-tagged fields are NEVER included here —
484
- // `view.changes` is drained without a per-field tag re-check,
485
- // so anything added leaks straight to the wire.
486
- const tags = changeTree.encDescriptor.tags;
487
- changeTree.forEachLive((index) => {
488
- const tagAtIndex = tags[index];
489
- if (
490
- tagAtIndex === undefined || // "all change" with no tag
491
- tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
492
- (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
493
- ) {
494
- changes.set(index, OPERATION.ADD);
495
+ // Full-sync snapshot of a non-new tree (fresh ones ship via .encode()).
496
+ // Also runs for custom tags when bootstrapping the tree for this view
497
+ // (!wasVisible) — the per-field filter admits untagged fields, and
498
+ // collections behind tagged fields have no `byTag`: without the
499
+ // snapshot their elements are never introduced ("refId" not found).
500
+ // A tagged add on an already-visible tree stays incremental (byTag
501
+ // only); default-tag re-adds re-snapshot on purpose (see `items`
502
+ // dedup note above).
503
+ if ((tag === DEFAULT_VIEW_TAG || !wasVisible) && (!changeTree.isNew || isChildAdded)) {
504
+ if (changeTree.isArray && typeof (changeTree.refTarget as any)[$childType] !== "string") {
505
+ // Ref-typed ArraySchema (the only proxied collection): one
506
+ // sentinel entry encodeView snapshots the live elements at
507
+ // drain time, so the slots survive a same-tick reindex (see
508
+ // `changes` field docs) and the write stays O(1).
509
+ if ((changeTree.refTarget as any).items.length > 0) {
510
+ changes.set(ARRAY_SNAPSHOT, OPERATION.ADD);
495
511
  isChildAdded = true;
496
512
  }
497
- });
513
+
514
+ } else {
515
+ // Full-sync snapshot: walk the live ref structurally instead of
516
+ // iterating a cumulative recorder bucket. Every populated index
517
+ // is emitted as ADD (matching the op-coercion previously done
518
+ // at encode time). Per-field tags come from the descriptor's
519
+ // precomputed `tags[]` array — direct index vs a metadata[i].tag
520
+ // object hop.
521
+ //
522
+ // Non-matching custom-tagged fields are NEVER included here —
523
+ // `view.changes` is drained without a per-field tag re-check,
524
+ // so anything added leaks straight to the wire.
525
+ const tags = changeTree.encDescriptor.tags;
526
+ changeTree.forEachLive((index) => {
527
+ const tagAtIndex = tags[index];
528
+ if (
529
+ tagAtIndex === undefined || // "all change" with no tag
530
+ tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
531
+ (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
532
+ ) {
533
+ changes.set(index, OPERATION.ADD);
534
+ isChildAdded = true;
535
+ }
536
+ });
537
+ }
498
538
  }
499
539
 
500
540
  return isChildAdded;
@@ -575,13 +615,21 @@ export class StateView {
575
615
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
576
616
  let changes = this.changes.get(changeTree.ref[$refId]);
577
617
  if (changes === undefined) {
578
- changes = new Map<number, OPERATION>();
618
+ changes = new Map<number | ChangeTree, OPERATION>();
579
619
  this.changes.set(changeTree.ref[$refId], changes);
580
620
  }
581
621
 
582
622
  this.addTag(changeTree, tag);
583
623
 
584
- changes.set(parentIndex, OPERATION.ADD);
624
+ // ArraySchema parents: key by the child's identity, not the wire
625
+ // slot it holds right now — a same-tick unshift()/reverse()/move()
626
+ // would shift the slot before encodeView drains this entry. Other
627
+ // parents keep numeric keys (Schema fields, MapSchema journal
628
+ // indexes and Set/Collection indexes are stable within a tick).
629
+ changes.set(
630
+ changeTree.isArray ? childChangeTree : parentIndex,
631
+ OPERATION.ADD,
632
+ );
585
633
  }
586
634
  }
587
635
 
@@ -641,8 +689,8 @@ export class StateView {
641
689
  // out of the stream's per-view state. If it never made it to the
642
690
  // wire (still in pending), silent drop; if already sent, queue
643
691
  // DELETE via `view.changes` for the next encodeView drain.
644
- const parentStreamTree = changeTree.parent?.[$changes];
645
- if (parentStreamTree?.isStreamCollection) {
692
+ const parentTree = changeTree.parent?.[$changes];
693
+ if (parentTree?.isStreamCollection) {
646
694
  this.unmarkVisible(changeTree);
647
695
  if (this.iterable && !_isClear) {
648
696
  spliceOne(this.items, this.items.indexOf(obj));
@@ -718,59 +766,46 @@ export class StateView {
718
766
  // parent is collection (Map/Array)
719
767
  const parent = changeTree.parent;
720
768
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
769
+ // ArraySchema parents use identity keys (see `changes` field
770
+ // docs); Map parents keep the (stable) journal index.
771
+ const key = parentTree!.isArray
772
+ ? changeTree
773
+ : changeTree.parentIndex;
721
774
  const parentRefId = parent[$refId];
722
775
  let changes = this.changes.get(parentRefId);
723
776
  if (changes === undefined) {
724
- changes = new Map<number, OPERATION>();
777
+ changes = new Map<number | ChangeTree, OPERATION>();
725
778
  this.changes.set(parentRefId, changes);
726
779
 
727
- } else if (changes.get(changeTree.parentIndex) === OPERATION.ADD) {
780
+ } else if (changes.get(key) === OPERATION.ADD) {
728
781
  //
729
782
  // SAME PATCH ADD + REMOVE:
730
- // The 'changes' of deleted structure should be ignored.
783
+ // cancel the structure's pending ops and its descendants'
784
+ // their introduction never reaches this client.
731
785
  //
732
- this.changes.delete(refId);
786
+ this._dropPendingEntries(changeTree);
733
787
  }
734
788
 
735
789
  // DELETE / DELETE BY REF ID
736
- changes.set(changeTree.parentIndex, OPERATION.DELETE);
790
+ changes.set(key, OPERATION.DELETE);
737
791
 
738
792
  // Remove child schema from visible set
739
793
  this._recursiveDeleteVisibleChangeTree(changeTree);
740
794
 
741
795
  } else {
742
796
  // delete all "tagged" properties.
743
- const names = changeTree.encDescriptor.names;
744
- metadata?.[$viewFieldIndexes]?.forEach((index) => {
745
- changes.set(index, OPERATION.DELETE);
746
-
747
- // Remove child structures of @view() fields from visible set.
748
- // (They were added during view.add() via forEachChild)
749
- const value = changeTree.ref[names[index] as keyof Ref];
750
- if (value?.[$changes]) {
751
- this.unmarkVisible(value[$changes]);
752
- this._recursiveDeleteVisibleChangeTree(value[$changes]);
753
- }
754
- });
797
+ metadata?.[$viewFieldIndexes]?.forEach((index) =>
798
+ this._removeViewField(changeTree, changes, index));
755
799
  }
756
800
 
757
801
  } else {
758
802
  // delete only tagged properties. `$fieldIndexesByViewTag` is
759
803
  // keyed per-bit, so a combined tag iterates each set bit.
760
- const names = changeTree.encDescriptor.names;
761
804
  const byTag = metadata?.[$fieldIndexesByViewTag];
762
805
  if (byTag !== undefined) {
763
806
  for (let bits = tag; bits > 0; bits &= bits - 1) {
764
- byTag[bits & -bits]?.forEach((index) => {
765
- changes.set(index, OPERATION.DELETE);
766
-
767
- // Remove child structures from visible set
768
- const value = changeTree.ref[names[index] as keyof Ref];
769
- if (value?.[$changes]) {
770
- this.unmarkVisible(value[$changes]);
771
- this._recursiveDeleteVisibleChangeTree(value[$changes]);
772
- }
773
- });
807
+ byTag[bits & -bits]?.forEach((index) =>
808
+ this._removeViewField(changeTree, changes, index));
774
809
  }
775
810
  }
776
811
  }
@@ -956,14 +991,16 @@ export class StateView {
956
991
  } else {
957
992
  // Non-streams: queue DELETE for every current child and
958
993
  // unmark their visibility so subsequent mutations stop
959
- // reaching this view.
994
+ // reaching this view. ArraySchema children are keyed by identity
995
+ // (see `changes` field docs); others by their stable index.
996
+ const isArray = tree.isArray;
960
997
  let changes = this.changes.get(collectionRefId);
961
998
  tree.forEachChild((childTree, index) => {
962
999
  if (changes === undefined) {
963
1000
  changes = new Map();
964
1001
  this.changes.set(collectionRefId, changes);
965
1002
  }
966
- changes.set(index, OPERATION.DELETE);
1003
+ changes.set(isArray ? childTree : index, OPERATION.DELETE);
967
1004
  this.unmarkVisible(childTree);
968
1005
  });
969
1006
  }
@@ -999,9 +1036,24 @@ export class StateView {
999
1036
  // `markVisible` memoizes so the branch fires at most once per
1000
1037
  // (tree, view) pair.
1001
1038
  if (!isVisible && changeTree.isVisibilitySharedWithParent){
1039
+ // Primary grant is intentionally unguarded — pre-existing
1040
+ // semantics; the extras walk below is stricter on purpose.
1002
1041
  if (this.isVisible(changeTree.parent[$changes])) {
1003
1042
  this.markVisible(changeTree);
1004
1043
  isVisible = true;
1044
+ } else {
1045
+ // Shared instance: the sharing parent may sit anywhere in the
1046
+ // chain — addParent promotes the LAST container to primary.
1047
+ // Only filtered parents can grant (public ones never share
1048
+ // visibility downward).
1049
+ for (let e = changeTree.extraParents; e !== undefined; e = e.next) {
1050
+ const parentTree = e.ref[$changes];
1051
+ if (parentTree.isFiltered && this.isVisible(parentTree)) {
1052
+ this.markVisible(changeTree);
1053
+ isVisible = true;
1054
+ break;
1055
+ }
1056
+ }
1005
1057
  }
1006
1058
  }
1007
1059
 
@@ -1014,4 +1066,36 @@ export class StateView {
1014
1066
  this._recursiveDeleteVisibleChangeTree(childChangeTree);
1015
1067
  });
1016
1068
  }
1069
+
1070
+ /**
1071
+ * Drop the pending `view.changes` entries of `tree` and every descendant.
1072
+ * Called when a same-patch pending ADD is cancelled: the subtree's
1073
+ * introduction never reaches this client, so its entries would emit
1074
+ * refIds the decoder cannot resolve ("refId" not found).
1075
+ */
1076
+ private _dropPendingEntries(tree: ChangeTree): void {
1077
+ this.changes.delete(tree.ref[$refId]);
1078
+ tree.forEachChild((child) => this._dropPendingEntries(child));
1079
+ }
1080
+
1081
+ /**
1082
+ * Queue DELETE for a @view field on `changes` and hide the field
1083
+ * value's subtree from this view. When the field's ADD is still
1084
+ * pending (same-patch add + remove), the value's introduction never
1085
+ * ships — its pending subtree entries are dropped along with it.
1086
+ */
1087
+ private _removeViewField(changeTree: ChangeTree, changes: Map<number | ChangeTree, OPERATION>, index: number): void {
1088
+ const wasPendingAdd = changes.get(index) === OPERATION.ADD;
1089
+ changes.set(index, OPERATION.DELETE);
1090
+
1091
+ const value = changeTree.ref[changeTree.encDescriptor.names[index] as keyof Ref];
1092
+ const valueTree: ChangeTree = value?.[$changes];
1093
+ if (valueTree) {
1094
+ this.unmarkVisible(valueTree);
1095
+ this._recursiveDeleteVisibleChangeTree(valueTree);
1096
+ if (wasPendingAdd) {
1097
+ this._dropPendingEntries(valueTree);
1098
+ }
1099
+ }
1100
+ }
1017
1101
  }