@colyseus/schema 5.0.2 → 5.0.4

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.
@@ -36,12 +36,12 @@ export declare class StateCallbackStrategy<TState extends IRef> {
36
36
  /**
37
37
  * Listen to property changes on a nested instance.
38
38
  */
39
- listen<TInstance extends Schema, K extends PublicPropNames<TInstance>>(instance: TInstance, property: K, handler: PropertyChangeCallback<TInstance[K]>, immediate?: boolean): () => void;
39
+ listen<TInstance, K extends PublicPropNames<TInstance>>(instance: TInstance, property: K, handler: PropertyChangeCallback<TInstance[K]>, immediate?: boolean): () => void;
40
40
  protected listenInstance<TInstance extends IRef>(instance: TInstance, propertyName: string, handler: PropertyChangeCallback<any>, immediate?: boolean): () => void;
41
41
  /**
42
42
  * Listen to any property change on an instance.
43
43
  */
44
- onChange<TInstance extends Schema>(instance: TInstance, handler: InstanceChangeCallback): () => void;
44
+ onChange<TInstance extends object>(instance: TInstance, handler: InstanceChangeCallback): () => void;
45
45
  /**
46
46
  * Listen to item changes in a collection on root state.
47
47
  */
@@ -49,7 +49,7 @@ export declare class StateCallbackStrategy<TState extends IRef> {
49
49
  /**
50
50
  * Listen to item changes in a nested collection.
51
51
  */
52
- onChange<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(instance: TInstance, property: K, handler: KeyValueCallback<CollectionKeyType<TInstance, K>, CollectionValueType<TInstance, K>>): () => void;
52
+ onChange<TInstance extends object, K extends CollectionPropNames<TInstance>>(instance: TInstance, property: K, handler: KeyValueCallback<CollectionKeyType<TInstance, K>, CollectionValueType<TInstance, K>>): () => void;
53
53
  /**
54
54
  * Listen to items added to a collection on root state.
55
55
  */
@@ -57,7 +57,7 @@ export declare class StateCallbackStrategy<TState extends IRef> {
57
57
  /**
58
58
  * Listen to items added to a nested collection.
59
59
  */
60
- onAdd<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(instance: TInstance, property: K, handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>, immediate?: boolean): () => void;
60
+ onAdd<TInstance, K extends CollectionPropNames<TInstance>>(instance: TInstance, property: K, handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>, immediate?: boolean): () => void;
61
61
  /**
62
62
  * Listen to items removed from a collection on root state.
63
63
  */
@@ -65,12 +65,12 @@ export declare class StateCallbackStrategy<TState extends IRef> {
65
65
  /**
66
66
  * Listen to items removed from a nested collection.
67
67
  */
68
- onRemove<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(instance: TInstance, property: K, handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>): () => void;
68
+ onRemove<TInstance, K extends CollectionPropNames<TInstance>>(instance: TInstance, property: K, handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>): () => void;
69
69
  /**
70
70
  * Bind properties from a Schema instance to a target object.
71
71
  * Changes will be automatically reflected on the target object.
72
72
  */
73
- bindTo<TInstance extends Schema, TTarget>(from: TInstance, to: TTarget, properties?: string[], immediate?: boolean): () => void;
73
+ bindTo<TInstance, TTarget>(from: TInstance, to: TTarget, properties?: string[], immediate?: boolean): () => void;
74
74
  protected triggerChanges(allChanges: DataChange[]): void;
75
75
  }
76
76
  /**
@@ -107,6 +107,23 @@ export declare class StateView {
107
107
  */
108
108
  private _markSubtreeVisible;
109
109
  protected addParentOf(childChangeTree: ChangeTree, tag: number): void;
110
+ /**
111
+ * Walk `tree`'s parent chain to root and insert an empty entry into
112
+ * `view.changes` for any ancestor not already present. Empty entries
113
+ * are skipped by `encodeView` (`changes.size === 0` continue), so no
114
+ * wire bytes are emitted — but the Map's insertion order now puts
115
+ * each ancestor BEFORE the descendant entry that the caller is about
116
+ * to write. Combined with `addParentOf`'s full-recursion walk on
117
+ * `view.add`, this preserves the global invariant that
118
+ * `view.changes` iteration order is topological.
119
+ *
120
+ * Iterative (not recursive) so the stack is bounded by tree depth
121
+ * regardless of call patterns. Stops the walk as soon as it hits an
122
+ * ancestor that's already in `view.changes` — at that point the
123
+ * remainder of the chain is guaranteed to also be present (invariant
124
+ * upheld by every prior caller).
125
+ */
126
+ private _touchAncestorsOf;
110
127
  remove(obj: Ref, tag?: number): this;
111
128
  remove(obj: Ref, tag?: number, _isClear?: boolean): this;
112
129
  has(obj: Ref): boolean;
package/build/index.cjs CHANGED
@@ -1214,6 +1214,33 @@ const Metadata = {
1214
1214
  getStreamPriority(metadata, index) {
1215
1215
  return metadata?.[$streamPriorities]?.[index];
1216
1216
  },
1217
+ /**
1218
+ * Install a single field with full encoder wiring: accessor descriptor
1219
+ * on the prototype + `metadata[$encoders]` slot for primitives. Shared
1220
+ * between `Metadata.setFields` (build path) and
1221
+ * `Reflection.makeEncodable` (Reflection upgrade path).
1222
+ */
1223
+ defineField(target, metadata, fieldIndex, fieldName, type) {
1224
+ const normalized = getNormalizedType(type);
1225
+ const { complexTypeKlass, childType } = resolveFieldType(normalized);
1226
+ Metadata.addField(metadata, fieldIndex, fieldName, normalized, getPropertyDescriptor(fieldName, fieldIndex, childType, complexTypeKlass));
1227
+ // Install accessor descriptor on the prototype (once per class field).
1228
+ if (metadata[$descriptors][fieldName]) {
1229
+ Object.defineProperty(target.prototype, fieldName, metadata[$descriptors][fieldName]);
1230
+ }
1231
+ // Pre-compute encoder function for primitive types.
1232
+ if (typeof normalized === "string") {
1233
+ if (!metadata[$encoders]) {
1234
+ Object.defineProperty(metadata, $encoders, {
1235
+ value: [],
1236
+ enumerable: false,
1237
+ configurable: true,
1238
+ writable: true,
1239
+ });
1240
+ }
1241
+ metadata[$encoders][fieldIndex] = encode[normalized];
1242
+ }
1243
+ },
1217
1244
  setFields(target, fields) {
1218
1245
  // for inheritance support
1219
1246
  const constructor = target.prototype.constructor;
@@ -1251,17 +1278,7 @@ const Metadata = {
1251
1278
  });
1252
1279
  }
1253
1280
  for (const field in fields) {
1254
- const type = getNormalizedType(fields[field]);
1255
- const { complexTypeKlass, childType } = resolveFieldType(type);
1256
- Metadata.addField(metadata, fieldIndex, field, type, getPropertyDescriptor(field, fieldIndex, childType, complexTypeKlass));
1257
- // Install accessor descriptor on the prototype (once per class field).
1258
- if (metadata[$descriptors][field]) {
1259
- Object.defineProperty(target.prototype, field, metadata[$descriptors][field]);
1260
- }
1261
- // Pre-compute encoder function for primitive types.
1262
- if (typeof type === "string") {
1263
- metadata[$encoders][fieldIndex] = encode[type];
1264
- }
1281
+ Metadata.defineField(constructor, metadata, fieldIndex, field, fields[field]);
1265
1282
  fieldIndex++;
1266
1283
  }
1267
1284
  return target;
@@ -2093,11 +2110,24 @@ function checkInheritedFlags(tree, parent, parentIndex) {
2093
2110
  const refType = Metadata.isValidInstance(tree.ref)
2094
2111
  ? tree.ref.constructor
2095
2112
  : tree.ref[$childType];
2113
+ // #218: nested Schema fields inherit visibility from a @view-gated
2114
+ // parent regardless of whether the parent is a collection. The
2115
+ // `parentIsCollection` constraint that used to live here blocked
2116
+ // nested-Schema-field-of-@view-tagged-Schema from sharing visibility,
2117
+ // forcing users to wrap the child in an ArraySchema as a workaround.
2118
+ //
2119
+ // #226 (4.0.25): items inside a non-default-tag `@view(N)` collection
2120
+ // also inherit visibility from the parent collection, so items
2121
+ // pushed/set after `view.add(state, N)` show up automatically.
2122
+ // Default-tag `@view()` collections keep per-item gating —
2123
+ // `view.add(item)` is still required to opt each one in.
2124
+ // The `parentMetadata[parentIndex].tag` access is safe inside the
2125
+ // `fieldHasViewTag` short-circuit (the metadata entry and its `tag`
2126
+ // are guaranteed to exist when that flag is set).
2096
2127
  tree.isVisibilitySharedWithParent = (parentChangeTree.isFiltered
2097
2128
  && typeof refType !== "string"
2098
- && !fieldHasViewTag
2099
2129
  && !fieldHasStream
2100
- && parentIsCollection);
2130
+ && (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG)));
2101
2131
  }
2102
2132
  }
2103
2133
 
@@ -5430,8 +5460,10 @@ registerType("stream", { constructor: StreamSchema });
5430
5460
  */
5431
5461
  class FieldBuilder {
5432
5462
  [$builder] = true;
5433
- // Internal configuration. Public so schema() and tests can read it, but not
5434
- // meant to be mutated by users directly.
5463
+ // Internal configuration. Declared `private` (soft-private): hidden from
5464
+ // editor autocomplete and from normal external `.field` access, but still
5465
+ // reachable at runtime via element access (e.g. `builder['_noSync']`) for
5466
+ // internal tooling/tests. Not meant to be mutated by end users.
5435
5467
  _type;
5436
5468
  _default = undefined;
5437
5469
  _hasDefault = false;
@@ -5444,6 +5476,7 @@ class FieldBuilder {
5444
5476
  _static = false;
5445
5477
  _stream = false;
5446
5478
  _optional = false;
5479
+ _noSync = false;
5447
5480
  _streamPriority = undefined;
5448
5481
  constructor(type) {
5449
5482
  this._type = type;
@@ -5494,6 +5527,30 @@ class FieldBuilder {
5494
5527
  this._static = true;
5495
5528
  return this;
5496
5529
  }
5530
+ /**
5531
+ * Mark this field as **local-only** — it is typed and initialized on the
5532
+ * instance (so `.default()` and the inferred instance type still apply),
5533
+ * but is never registered for synchronization: it never enters change
5534
+ * tracking, never goes over the wire, and decoders never receive it.
5535
+ *
5536
+ * Useful for server-side scratch state, per-peer UI state, or values you
5537
+ * want on the class for typing convenience without paying any sync cost.
5538
+ *
5539
+ * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
5540
+ * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
5541
+ * them throws at `schema()` time.
5542
+ *
5543
+ * ```ts
5544
+ * const Player = schema({
5545
+ * hp: t.uint8().default(100), // synchronized
5546
+ * lastInputTick: t.number().noSync(), // local-only
5547
+ * }, 'Player');
5548
+ * ```
5549
+ */
5550
+ noSync() {
5551
+ this._noSync = true;
5552
+ return this;
5553
+ }
5497
5554
  /**
5498
5555
  * Opt a collection field into priority-batched streaming delivery —
5499
5556
  * ADDs drain at most `maxPerTick` per tick per view (or per broadcast
@@ -5548,6 +5605,11 @@ class FieldBuilder {
5548
5605
  this._optional = true;
5549
5606
  return this;
5550
5607
  }
5608
+ /**
5609
+ * @internal — snapshot of the builder's configuration consumed by
5610
+ * `schema()`. `private` keeps it out of autocomplete; internal callers
5611
+ * reach it via element access (`builder['toDefinition']()`).
5612
+ */
5551
5613
  toDefinition() {
5552
5614
  return {
5553
5615
  type: this._type,
@@ -5562,6 +5624,7 @@ class FieldBuilder {
5562
5624
  static: this._static,
5563
5625
  stream: this._stream,
5564
5626
  optional: this._optional,
5627
+ noSync: this._noSync,
5565
5628
  streamPriority: this._streamPriority,
5566
5629
  };
5567
5630
  }
@@ -5577,7 +5640,8 @@ function primitive(name) {
5577
5640
  }
5578
5641
  function resolveChild(child) {
5579
5642
  if (isBuilder(child)) {
5580
- return child._type;
5643
+ // `_type` is private; element access bypasses the visibility check.
5644
+ return child['_type'];
5581
5645
  }
5582
5646
  return child;
5583
5647
  }
@@ -5587,7 +5651,7 @@ const setFactory = ((child) => new FieldBuilder({ set: resolveChild(child) }));
5587
5651
  const collectionFactory = ((child) => new FieldBuilder({ collection: resolveChild(child) }));
5588
5652
  const streamFactory = ((child) => {
5589
5653
  const b = new FieldBuilder({ stream: resolveChild(child) });
5590
- b._stream = true;
5654
+ b['_stream'] = true; // element access bypasses `private`
5591
5655
  return b;
5592
5656
  });
5593
5657
  const refFactory = ((ctor) => new FieldBuilder(ctor));
@@ -6016,6 +6080,36 @@ function deprecated(throws = true) {
6016
6080
  });
6017
6081
  };
6018
6082
  }
6083
+ /**
6084
+ * Produce the auto-instantiated construction default for a builder type
6085
+ * (empty collection or zero-arg Schema ref), or `undefined` when the type
6086
+ * has no auto-default. Shared by synced and `.noSync()` field handling.
6087
+ */
6088
+ function autoInstantiateDefault(rawType) {
6089
+ if (rawType && typeof rawType === "object") {
6090
+ if (rawType.array !== undefined) {
6091
+ return new ArraySchema();
6092
+ }
6093
+ if (rawType.map !== undefined) {
6094
+ return new MapSchema();
6095
+ }
6096
+ if (rawType.set !== undefined) {
6097
+ return new SetSchema();
6098
+ }
6099
+ if (rawType.collection !== undefined) {
6100
+ return new CollectionSchema();
6101
+ }
6102
+ if (rawType.stream !== undefined) {
6103
+ return new StreamSchema();
6104
+ }
6105
+ }
6106
+ else if (typeof rawType === "function" && Schema.is(rawType)) {
6107
+ if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6108
+ return new rawType();
6109
+ }
6110
+ }
6111
+ return undefined;
6112
+ }
6019
6113
  /**
6020
6114
  * Define a Schema class declaratively.
6021
6115
  *
@@ -6051,7 +6145,28 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6051
6145
  for (const fieldName in fieldsAndMethods) {
6052
6146
  const value = fieldsAndMethods[fieldName];
6053
6147
  if (isBuilder(value)) {
6054
- const def = value.toDefinition();
6148
+ const def = value['toDefinition'](); // private; element access bypasses visibility
6149
+ if (def.noSync) {
6150
+ // Local-only field: skip metadata registration entirely so it is
6151
+ // never encoded/decoded, but still seed its construction default
6152
+ // (honoring `.default()` and collection/ref auto-instantiation).
6153
+ if (def.view !== undefined || def.owned || def.unreliable ||
6154
+ def.transient || def.static || def.stream) {
6155
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
6156
+ `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
6157
+ `A local-only field cannot be synchronized.`);
6158
+ }
6159
+ if (def.hasDefault) {
6160
+ defaultValues[fieldName] = def.default;
6161
+ }
6162
+ else if (!def.optional) {
6163
+ const autoDefault = autoInstantiateDefault(def.type);
6164
+ if (autoDefault !== undefined) {
6165
+ defaultValues[fieldName] = autoDefault;
6166
+ }
6167
+ }
6168
+ continue;
6169
+ }
6055
6170
  fields[fieldName] = getNormalizedType(def.type);
6056
6171
  if (def.view !== undefined) {
6057
6172
  viewTagFields[fieldName] = def.view;
@@ -6086,28 +6201,9 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6086
6201
  else if (!def.optional) {
6087
6202
  // Auto-instantiate collection/Schema defaults when none is provided.
6088
6203
  // `.optional()` opts out — field starts as undefined.
6089
- const rawType = def.type;
6090
- if (rawType && typeof rawType === "object") {
6091
- if (rawType.array !== undefined) {
6092
- defaultValues[fieldName] = new ArraySchema();
6093
- }
6094
- else if (rawType.map !== undefined) {
6095
- defaultValues[fieldName] = new MapSchema();
6096
- }
6097
- else if (rawType.set !== undefined) {
6098
- defaultValues[fieldName] = new SetSchema();
6099
- }
6100
- else if (rawType.collection !== undefined) {
6101
- defaultValues[fieldName] = new CollectionSchema();
6102
- }
6103
- else if (rawType.stream !== undefined) {
6104
- defaultValues[fieldName] = new StreamSchema();
6105
- }
6106
- }
6107
- else if (typeof rawType === "function" && Schema.is(rawType)) {
6108
- if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6109
- defaultValues[fieldName] = new rawType();
6110
- }
6204
+ const autoDefault = autoInstantiateDefault(def.type);
6205
+ if (autoDefault !== undefined) {
6206
+ defaultValues[fieldName] = autoDefault;
6111
6207
  }
6112
6208
  }
6113
6209
  }
@@ -7346,8 +7442,19 @@ class Encoder {
7346
7442
  // selected element is passed to `view.add()` which populates
7347
7443
  // view.changes with the stream-link ADD + element-field ADDs.
7348
7444
  this._emitStreamPriority(view);
7349
- // encode visibility-triggered changes collected by view.add()
7350
- for (const [refId, changes] of view.changes) {
7445
+ //
7446
+ // `view.changes` Map insertion order IS topological order:
7447
+ // - `view.add` walks the parent chain to root via `addParentOf`
7448
+ // (depth-first ancestor-first), inserting every ancestor's
7449
+ // entry before the descendant's.
7450
+ // - `view.remove` calls `_touchAncestorsOf` before its own
7451
+ // write to insert any missing ancestors at the front of the
7452
+ // chain — empty entries that get skipped by the size==0
7453
+ // check below but establish Map position.
7454
+ // No per-encode topo sort needed.
7455
+ //
7456
+ for (const refId of view.changes.keys()) {
7457
+ const changes = view.changes.get(refId);
7351
7458
  const changeTree = this.root.changeTrees[refId];
7352
7459
  if (changeTree === undefined) {
7353
7460
  // detached instance, remove from view and skip.
@@ -8108,6 +8215,31 @@ Reflection.decode = function (bytes, it) {
8108
8215
  const state = new (typeContext.get(reflection.rootType || 0))();
8109
8216
  return new Decoder(state, typeContext);
8110
8217
  };
8218
+ Reflection.makeEncodable = function (ctor) {
8219
+ const metadata = ctor[Symbol.metadata];
8220
+ if (!metadata)
8221
+ return ctor;
8222
+ const numFields = metadata[$numFields];
8223
+ if (numFields === undefined)
8224
+ return ctor;
8225
+ // Walk every field index across the inheritance chain. Repeat calls
8226
+ // are cheap: defineField overwrites the same descriptor and re-stamps
8227
+ // the same `metadata[$encoders]` slot (idempotent).
8228
+ for (let i = 0; i <= numFields; i++) {
8229
+ const field = metadata[i];
8230
+ if (!field)
8231
+ continue;
8232
+ Metadata.defineField(ctor, metadata, i, field.name, field.type);
8233
+ }
8234
+ // Invalidate any cached encode descriptor — `getEncodeDescriptor`
8235
+ // memoizes on the constructor. If something already constructed it
8236
+ // (e.g. a prior `InputEncoder(...)` call that threw), drop the stale
8237
+ // entry so the next read sees the upgraded metadata.
8238
+ if (Object.prototype.hasOwnProperty.call(ctor, $encodeDescriptor)) {
8239
+ delete ctor[$encodeDescriptor];
8240
+ }
8241
+ return ctor;
8242
+ };
8111
8243
 
8112
8244
  /**
8113
8245
  * Legacy callback system
@@ -8724,6 +8856,7 @@ const Callbacks = {
8724
8856
  else if ('decoder' in roomOrDecoder.serializer) {
8725
8857
  return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
8726
8858
  }
8859
+ throw new Error('Invalid room or decoder');
8727
8860
  },
8728
8861
  getRawChanges(decoder, callback) {
8729
8862
  return getRawChangesCallback(decoder, callback);
@@ -9179,12 +9312,27 @@ class StateView {
9179
9312
  if (!this.isVisible(changeTree)) {
9180
9313
  // view must have all "changeTree" parent tree
9181
9314
  this.markVisible(changeTree);
9182
- // add parent's parent
9315
+ // Recurse all the way to the root regardless of whether the
9316
+ // parent is filtered. Walking the full chain is what makes
9317
+ // `view.changes` topologically ordered by construction — any
9318
+ // filtered ancestor up the chain is touched here, before the
9319
+ // descendant's entry. The actual entry-write below is gated
9320
+ // on `hasFilteredFields` so non-filtered ancestors don't
9321
+ // emit redundant wire bytes (the decoder already knows them
9322
+ // via the shared encode pass). Marking them visible is
9323
+ // still useful: it makes this short-circuit fire on the next
9324
+ // `view.add` instead of re-walking the chain.
9183
9325
  const parentChangeTree = changeTree.parent?.[$changes];
9184
- if (parentChangeTree && parentChangeTree.hasFilteredFields) {
9326
+ if (parentChangeTree) {
9185
9327
  this.addParentOf(changeTree, tag);
9186
9328
  }
9187
9329
  }
9330
+ // Skip the entry-write for non-filtered ancestors: their refIds
9331
+ // are already known to the decoder through the shared pass, and
9332
+ // an extra ADD on a non-filtered field's index would only emit
9333
+ // bytes for a no-op (`value === previousValue` on the decoder).
9334
+ if (!changeTree.hasFilteredFields)
9335
+ return;
9188
9336
  // add parent's tag properties
9189
9337
  if (changeTree.getChange(parentIndex) !== exports.OPERATION.DELETE) {
9190
9338
  let changes = this.changes.get(changeTree.ref[$refId]);
@@ -9196,6 +9344,45 @@ class StateView {
9196
9344
  changes.set(parentIndex, exports.OPERATION.ADD);
9197
9345
  }
9198
9346
  }
9347
+ /**
9348
+ * Walk `tree`'s parent chain to root and insert an empty entry into
9349
+ * `view.changes` for any ancestor not already present. Empty entries
9350
+ * are skipped by `encodeView` (`changes.size === 0` continue), so no
9351
+ * wire bytes are emitted — but the Map's insertion order now puts
9352
+ * each ancestor BEFORE the descendant entry that the caller is about
9353
+ * to write. Combined with `addParentOf`'s full-recursion walk on
9354
+ * `view.add`, this preserves the global invariant that
9355
+ * `view.changes` iteration order is topological.
9356
+ *
9357
+ * Iterative (not recursive) so the stack is bounded by tree depth
9358
+ * regardless of call patterns. Stops the walk as soon as it hits an
9359
+ * ancestor that's already in `view.changes` — at that point the
9360
+ * remainder of the chain is guaranteed to also be present (invariant
9361
+ * upheld by every prior caller).
9362
+ */
9363
+ _touchAncestorsOf(tree) {
9364
+ let cursor = tree.parent?.[$changes];
9365
+ if (cursor === undefined)
9366
+ return;
9367
+ // Collect the missing prefix of the chain, deepest-first. Only
9368
+ // FILTERED ancestors need entries — non-filtered ones never
9369
+ // appear in `view.changes` (mirrors the addParentOf gate), so
9370
+ // they don't need a Map slot reserved either.
9371
+ const stack = [];
9372
+ while (cursor !== undefined) {
9373
+ if (cursor.hasFilteredFields) {
9374
+ const refId = cursor.ref[$refId];
9375
+ if (this.changes.has(refId))
9376
+ break;
9377
+ stack.push(cursor);
9378
+ }
9379
+ cursor = cursor.parent?.[$changes];
9380
+ }
9381
+ // Insert root-first so Map order is topological.
9382
+ for (let i = stack.length - 1; i >= 0; i--) {
9383
+ this.changes.set(stack[i].ref[$refId], new Map());
9384
+ }
9385
+ }
9199
9386
  remove(obj, tag = DEFAULT_VIEW_TAG, _isClear = false) {
9200
9387
  const changeTree = obj[$changes];
9201
9388
  if (!changeTree) {
@@ -9256,6 +9443,11 @@ class StateView {
9256
9443
  const ref = changeTree.ref;
9257
9444
  const metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata
9258
9445
  const refId = ref[$refId];
9446
+ // Pre-insert any missing ancestors into view.changes so the Map's
9447
+ // iteration order stays topological — the entries we're about to
9448
+ // write (either on this obj, or on its parent collection below)
9449
+ // must come AFTER every ancestor in the chain on the wire.
9450
+ this._touchAncestorsOf(changeTree);
9259
9451
  let changes = this.changes.get(refId);
9260
9452
  if (changes === undefined) {
9261
9453
  changes = new Map();