@colyseus/schema 5.0.3 → 5.0.5

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.
@@ -23,6 +23,8 @@ export interface BuilderDefinition {
23
23
  static?: boolean;
24
24
  stream?: boolean;
25
25
  optional?: boolean;
26
+ /** Local-only field: typed + initialized, but never registered for sync. */
27
+ noSync?: boolean;
26
28
  /** Declaration-scope priority callback for `.stream()` fields. */
27
29
  streamPriority?: (view: any, element: any) => number;
28
30
  }
@@ -53,19 +55,20 @@ export type BuilderOf<T> = FieldBuilder<T>;
53
55
  */
54
56
  export declare class FieldBuilder<T = unknown, HasDefault extends boolean = false, IsOptional extends boolean = false> {
55
57
  readonly [$builder]: true;
56
- _type: DefinitionType;
57
- _default: any;
58
- _hasDefault: boolean;
59
- _view: number | undefined;
60
- _owned: boolean;
61
- _unreliable: boolean;
62
- _transient: boolean;
63
- _deprecated: boolean;
64
- _deprecatedThrows: boolean;
65
- _static: boolean;
66
- _stream: boolean;
67
- _optional: boolean;
68
- _streamPriority: ((view: any, element: any) => number) | undefined;
58
+ private _type;
59
+ private _default;
60
+ private _hasDefault;
61
+ private _view;
62
+ private _owned;
63
+ private _unreliable;
64
+ private _transient;
65
+ private _deprecated;
66
+ private _deprecatedThrows;
67
+ private _static;
68
+ private _stream;
69
+ private _optional;
70
+ private _noSync;
71
+ private _streamPriority;
69
72
  constructor(type: DefinitionType);
70
73
  /** Provide a default value for this field. */
71
74
  default(value: T): FieldBuilder<T, true, IsOptional>;
@@ -93,6 +96,27 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
93
96
  * after add — post-add field mutations on elements become no-ops.
94
97
  */
95
98
  static(): this;
99
+ /**
100
+ * Mark this field as **local-only** — it is typed and initialized on the
101
+ * instance (so `.default()` and the inferred instance type still apply),
102
+ * but is never registered for synchronization: it never enters change
103
+ * tracking, never goes over the wire, and decoders never receive it.
104
+ *
105
+ * Useful for server-side scratch state, per-peer UI state, or values you
106
+ * want on the class for typing convenience without paying any sync cost.
107
+ *
108
+ * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
109
+ * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
110
+ * them throws at `schema()` time.
111
+ *
112
+ * ```ts
113
+ * const Player = schema({
114
+ * hp: t.uint8().default(100), // synchronized
115
+ * lastInputTick: t.number().noSync(), // local-only
116
+ * }, 'Player');
117
+ * ```
118
+ */
119
+ noSync(): this;
96
120
  /**
97
121
  * Opt a collection field into priority-batched streaming delivery —
98
122
  * ADDs drain at most `maxPerTick` per tick per view (or per broadcast
@@ -130,9 +154,39 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
130
154
  * defaults, so the field starts as `undefined` at runtime.
131
155
  */
132
156
  optional(): FieldBuilder<T | undefined, HasDefault, true>;
133
- toDefinition(): BuilderDefinition;
157
+ /**
158
+ * @internal — snapshot of the builder's configuration consumed by
159
+ * `schema()`. `private` keeps it out of autocomplete; internal callers
160
+ * reach it via element access (`builder['toDefinition']()`).
161
+ */
162
+ private toDefinition;
134
163
  }
135
164
  export declare function isBuilder(value: any): value is FieldBuilder<any>;
165
+ /**
166
+ * Primitive field factory. Calling it bare (`t.int8()`) yields the natural type
167
+ * for the wire codec (`number` for the int/float formats, plus `string` /
168
+ * `boolean` / `bigint`). Pass an explicit type argument to refine the inferred
169
+ * value at the TYPE level, while the wire encoding is unchanged:
170
+ *
171
+ * moveX: t.int8<-1 | 0 | 1>(), // typed -1|0|1, still encoded as int8
172
+ * team: t.string<"red" | "blue">(),
173
+ *
174
+ * Two call signatures, NOT a defaulted generic `<T extends TBase = TBase>`: the
175
+ * bare form must return a CONCRETE `FieldBuilder<TBase>` so `schema({ x:
176
+ * t.number() })` still infers `x: number`. A defaulted free type parameter gets
177
+ * captured as `any` during `schema()`'s self-referential field inference (and
178
+ * `undefined extends any` then flips every field optional).
179
+ *
180
+ * NOTE: the refinement is a TYPE-LEVEL assertion, not a runtime guarantee — the
181
+ * wire still carries the codec's full range and the DECODER writes whatever
182
+ * bytes arrive. Sound for server-authored state; for INPUT schemas the value
183
+ * comes from an untrusted client (the type reads `-1|0|1` while a peer can send
184
+ * any int8), so keep validating/clamping on the receiving side.
185
+ */
186
+ interface PrimitiveFactory<TBase> {
187
+ (): FieldBuilder<TBase>;
188
+ <T extends TBase>(): FieldBuilder<T>;
189
+ }
136
190
  export type ChildType = RawPrimitiveType | Constructor<Schema> | FieldBuilder<any>;
137
191
  interface ArrayFactory {
138
192
  <C extends Constructor<Schema>>(child: C): FieldBuilder<ArraySchema<InstanceType<C>>, true, false>;
@@ -166,21 +220,21 @@ interface RefFactory {
166
220
  <C extends Constructor<Schema>>(ctor: C): FieldBuilder<InstanceType<C>, RefHasDefault<C>, false>;
167
221
  }
168
222
  export declare const t: Readonly<{
169
- string: () => FieldBuilder<string, false, false>;
170
- number: () => FieldBuilder<number, false, false>;
171
- boolean: () => FieldBuilder<boolean, false, false>;
172
- int8: () => FieldBuilder<number, false, false>;
173
- uint8: () => FieldBuilder<number, false, false>;
174
- int16: () => FieldBuilder<number, false, false>;
175
- uint16: () => FieldBuilder<number, false, false>;
176
- int32: () => FieldBuilder<number, false, false>;
177
- uint32: () => FieldBuilder<number, false, false>;
178
- int64: () => FieldBuilder<number, false, false>;
179
- uint64: () => FieldBuilder<number, false, false>;
180
- float32: () => FieldBuilder<number, false, false>;
181
- float64: () => FieldBuilder<number, false, false>;
182
- bigint64: () => FieldBuilder<bigint, false, false>;
183
- biguint64: () => FieldBuilder<bigint, false, false>;
223
+ string: PrimitiveFactory<string>;
224
+ number: PrimitiveFactory<number>;
225
+ boolean: PrimitiveFactory<boolean>;
226
+ int8: PrimitiveFactory<number>;
227
+ uint8: PrimitiveFactory<number>;
228
+ int16: PrimitiveFactory<number>;
229
+ uint16: PrimitiveFactory<number>;
230
+ int32: PrimitiveFactory<number>;
231
+ uint32: PrimitiveFactory<number>;
232
+ int64: PrimitiveFactory<number>;
233
+ uint64: PrimitiveFactory<number>;
234
+ float32: PrimitiveFactory<number>;
235
+ float64: PrimitiveFactory<number>;
236
+ bigint64: PrimitiveFactory<bigint>;
237
+ biguint64: PrimitiveFactory<bigint>;
184
238
  /** Reference to a Schema subtype. `t.array(Item)` usually reads better, but this is available when a plain ref is needed. */
185
239
  ref: RefFactory;
186
240
  array: ArrayFactory;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/schema",
3
- "version": "5.0.3",
3
+ "version": "5.0.5",
4
4
  "description": "Binary state serializer with delta encoding for games",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,8 +10,9 @@
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.build.json && rollup -c rollup.config.mjs",
12
12
  "watch": "tsc -p tsconfig.build.json -w",
13
- "typecheck": "tsc -p tsconfig.build.json --noEmit",
14
- "test": "tsx --tsconfig tsconfig.test.json ./node_modules/.bin/mocha test/*.test.ts test/**/*.test.ts",
13
+ "typecheck:build": "tsc -p tsconfig.build.json --noEmit",
14
+ "test": "tsx --tsconfig tsconfig.test.json ./node_modules/mocha/bin/mocha.js \"test/*.test.ts\" \"test/**/*.test.ts\"",
15
+ "typecheck": "tsc -p tsconfig.test.json",
15
16
  "coverage": "c8 npm run test",
16
17
  "generate-test-1": "bin/schema-codegen test-external/PrimitiveTypes.ts --namespace SchemaTest.PrimitiveTypes --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/PrimitiveTypes",
17
18
  "generate-test-2": "bin/schema-codegen test-external/ChildSchemaTypes.ts --namespace SchemaTest.ChildSchemaTypes --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/ChildSchemaTypes",
@@ -25,7 +26,7 @@
25
26
  "generate-test-10": "bin/schema-codegen test-external/Callbacks.ts --namespace SchemaTest.Callbacks --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/Callbacks",
26
27
  "generate-test-11": "bin/schema-codegen test-external/MapSchemaMoveNullifyType.ts --namespace SchemaTest.MapSchemaMoveNullifyType --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/MapSchemaMoveNullifyType",
27
28
  "generate-test-12": "bin/schema-codegen test-external/ArraySchemaClear --namespace SchemaTest.ArraySchemaClear --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/ArraySchemaClear",
28
- "prepublishOnly": "npm run build"
29
+ "prepare": "npm run build"
29
30
  },
30
31
  "files": [
31
32
  "src",
@@ -88,7 +89,7 @@
88
89
  "typescript": "^5.9.3"
89
90
  },
90
91
  "peerDependencies": {
91
- "typescript": "^5.9.3"
92
+ "typescript": "^5.0.0 || ^6.0.0"
92
93
  },
93
94
  "c8": {
94
95
  "include": [
@@ -619,6 +619,26 @@ export interface SchemaWithExtendsConstructor<
619
619
  };
620
620
  }
621
621
 
622
+ /**
623
+ * Produce the auto-instantiated construction default for a builder type
624
+ * (empty collection or zero-arg Schema ref), or `undefined` when the type
625
+ * has no auto-default. Shared by synced and `.noSync()` field handling.
626
+ */
627
+ function autoInstantiateDefault(rawType: any): any {
628
+ if (rawType && typeof rawType === "object") {
629
+ if (rawType.array !== undefined) { return new ArraySchema(); }
630
+ if (rawType.map !== undefined) { return new MapSchema(); }
631
+ if (rawType.set !== undefined) { return new SetSchema(); }
632
+ if (rawType.collection !== undefined) { return new CollectionSchema(); }
633
+ if (rawType.stream !== undefined) { return new StreamSchema(); }
634
+ } else if (typeof rawType === "function" && Schema.is(rawType)) {
635
+ if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
636
+ return new rawType();
637
+ }
638
+ }
639
+ return undefined;
640
+ }
641
+
622
642
  /**
623
643
  * Define a Schema class declaratively.
624
644
  *
@@ -664,7 +684,31 @@ export function schema<
664
684
  const value: any = (fieldsAndMethods as any)[fieldName];
665
685
 
666
686
  if (isBuilder(value)) {
667
- const def = value.toDefinition();
687
+ const def = value['toDefinition'](); // private; element access bypasses visibility
688
+
689
+ if (def.noSync) {
690
+ // Local-only field: skip metadata registration entirely so it is
691
+ // never encoded/decoded, but still seed its construction default
692
+ // (honoring `.default()` and collection/ref auto-instantiation).
693
+ if (def.view !== undefined || def.owned || def.unreliable ||
694
+ def.transient || def.static || def.stream) {
695
+ throw new Error(
696
+ `schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
697
+ `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
698
+ `A local-only field cannot be synchronized.`
699
+ );
700
+ }
701
+ if (def.hasDefault) {
702
+ defaultValues[fieldName] = def.default;
703
+ } else if (!def.optional) {
704
+ const autoDefault = autoInstantiateDefault(def.type);
705
+ if (autoDefault !== undefined) {
706
+ defaultValues[fieldName] = autoDefault;
707
+ }
708
+ }
709
+ continue;
710
+ }
711
+
668
712
  fields[fieldName] = getNormalizedType(def.type);
669
713
 
670
714
  if (def.view !== undefined) { viewTagFields[fieldName] = def.view; }
@@ -682,23 +726,9 @@ export function schema<
682
726
  } else if (!def.optional) {
683
727
  // Auto-instantiate collection/Schema defaults when none is provided.
684
728
  // `.optional()` opts out — field starts as undefined.
685
- const rawType: any = def.type;
686
- if (rawType && typeof rawType === "object") {
687
- if (rawType.array !== undefined) {
688
- defaultValues[fieldName] = new ArraySchema();
689
- } else if (rawType.map !== undefined) {
690
- defaultValues[fieldName] = new MapSchema();
691
- } else if (rawType.set !== undefined) {
692
- defaultValues[fieldName] = new SetSchema();
693
- } else if (rawType.collection !== undefined) {
694
- defaultValues[fieldName] = new CollectionSchema();
695
- } else if (rawType.stream !== undefined) {
696
- defaultValues[fieldName] = new StreamSchema();
697
- }
698
- } else if (typeof rawType === "function" && Schema.is(rawType)) {
699
- if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
700
- defaultValues[fieldName] = new rawType();
701
- }
729
+ const autoDefault = autoInstantiateDefault(def.type);
730
+ if (autoDefault !== undefined) {
731
+ defaultValues[fieldName] = autoDefault;
702
732
  }
703
733
  }
704
734
 
@@ -122,10 +122,34 @@ ${namespace ? "}" : ""}
122
122
  `;
123
123
  }
124
124
 
125
+ /**
126
+ * Check if all enum members resolve to non-negative integers,
127
+ * allowing emission as a native C# `enum` (which only supports integral types).
128
+ */
129
+ function canUseNativeEnum(_enum: Enum): boolean {
130
+ return _enum.properties.every((prop) => {
131
+ if (!prop.type) return true;
132
+ const n = Number(prop.type);
133
+ return Number.isInteger(n) && n >= 0;
134
+ });
135
+ }
136
+
125
137
  /**
126
138
  * Generate just the enum body (without imports/namespace) for bundling
127
139
  */
128
140
  function generateEnumBody(_enum: Enum, indent: string = ""): string {
141
+ if (canUseNativeEnum(_enum)) {
142
+ const members = _enum.properties
143
+ .map((prop, i) => {
144
+ const value = prop.type ? Number(prop.type) : i;
145
+ return `${indent}\t${prop.name} = ${value},`;
146
+ })
147
+ .join("\n");
148
+ return `${indent}public enum ${_enum.name} : int {
149
+ ${members}
150
+ ${indent}}`;
151
+ }
152
+
129
153
  return `${indent}public struct ${_enum.name} {
130
154
 
131
155
  ${_enum.properties
@@ -88,13 +88,13 @@ export class StateCallbackStrategy<TState extends IRef> {
88
88
  if (!collection || collection[$refId] === undefined) {
89
89
  let removePropertyCallback: () => void;
90
90
  removePropertyCallback = this.addCallback(
91
- instance[$refId],
91
+ instance[$refId]!,
92
92
  propertyName,
93
93
  (value: TReturn, _: TReturn) => {
94
94
  if (value !== null && value !== undefined) {
95
95
  // Remove the property listener now that collection is available
96
96
  removePropertyCallback();
97
- removeHandler = this.addCallback(value[$refId], operation, handler);
97
+ removeHandler = this.addCallback(value[$refId]!, operation, handler);
98
98
  }
99
99
  }
100
100
  );
@@ -113,7 +113,7 @@ export class StateCallbackStrategy<TState extends IRef> {
113
113
  });
114
114
  }
115
115
 
116
- return this.addCallback(collection[$refId], operation, handler);
116
+ return this.addCallback(collection[$refId]!, operation, handler);
117
117
  }
118
118
  }
119
119
 
@@ -129,7 +129,7 @@ export class StateCallbackStrategy<TState extends IRef> {
129
129
  /**
130
130
  * Listen to property changes on a nested instance.
131
131
  */
132
- listen<TInstance extends Schema, K extends PublicPropNames<TInstance>>(
132
+ listen<TInstance, K extends PublicPropNames<TInstance>>(
133
133
  instance: TInstance,
134
134
  property: K,
135
135
  handler: PropertyChangeCallback<TInstance[K]>,
@@ -162,13 +162,13 @@ export class StateCallbackStrategy<TState extends IRef> {
162
162
  handler(currentValue, undefined as any);
163
163
  }
164
164
 
165
- return this.addCallback(instance[$refId], propertyName, handler);
165
+ return this.addCallback(instance[$refId]!, propertyName, handler);
166
166
  }
167
167
 
168
168
  /**
169
169
  * Listen to any property change on an instance.
170
170
  */
171
- onChange<TInstance extends Schema>(
171
+ onChange<TInstance extends object>(
172
172
  instance: TInstance,
173
173
  handler: InstanceChangeCallback
174
174
  ): () => void;
@@ -184,7 +184,7 @@ export class StateCallbackStrategy<TState extends IRef> {
184
184
  /**
185
185
  * Listen to item changes in a nested collection.
186
186
  */
187
- onChange<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(
187
+ onChange<TInstance extends object, K extends CollectionPropNames<TInstance>>(
188
188
  instance: TInstance,
189
189
  property: K,
190
190
  handler: KeyValueCallback<CollectionKeyType<TInstance, K>, CollectionValueType<TInstance, K>>
@@ -195,7 +195,7 @@ export class StateCallbackStrategy<TState extends IRef> {
195
195
  // onChange(instance, handler) - instance change
196
196
  const instance = args[0] as Schema;
197
197
  const handler = args[1] as InstanceChangeCallback;
198
- return this.addCallback(instance[$refId], OPERATION.REPLACE, handler);
198
+ return this.addCallback(instance[$refId]!, OPERATION.REPLACE, handler);
199
199
  }
200
200
 
201
201
  if (typeof args[0] === 'string') {
@@ -229,7 +229,7 @@ export class StateCallbackStrategy<TState extends IRef> {
229
229
  /**
230
230
  * Listen to items added to a nested collection.
231
231
  */
232
- onAdd<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(
232
+ onAdd<TInstance, K extends CollectionPropNames<TInstance>>(
233
233
  instance: TInstance,
234
234
  property: K,
235
235
  handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>,
@@ -269,7 +269,7 @@ export class StateCallbackStrategy<TState extends IRef> {
269
269
  /**
270
270
  * Listen to items removed from a nested collection.
271
271
  */
272
- onRemove<TInstance extends Schema, K extends CollectionPropNames<TInstance>>(
272
+ onRemove<TInstance, K extends CollectionPropNames<TInstance>>(
273
273
  instance: TInstance,
274
274
  property: K,
275
275
  handler: ValueKeyCallback<CollectionValueType<TInstance, K>, CollectionKeyType<TInstance, K>>
@@ -299,7 +299,7 @@ export class StateCallbackStrategy<TState extends IRef> {
299
299
  * Bind properties from a Schema instance to a target object.
300
300
  * Changes will be automatically reflected on the target object.
301
301
  */
302
- bindTo<TInstance extends Schema, TTarget>(
302
+ bindTo<TInstance, TTarget>(
303
303
  from: TInstance,
304
304
  to: TTarget,
305
305
  properties?: string[],
@@ -327,7 +327,7 @@ export class StateCallbackStrategy<TState extends IRef> {
327
327
  action();
328
328
  }
329
329
 
330
- return this.addCallback(from[$refId], OPERATION.REPLACE, action);
330
+ return this.addCallback((from as IRef)[$refId]!, OPERATION.REPLACE, action);
331
331
  }
332
332
 
333
333
  protected triggerChanges(allChanges: DataChange[]): void {
@@ -357,7 +357,7 @@ export class StateCallbackStrategy<TState extends IRef> {
357
357
  (change.op & OPERATION.DELETE) === OPERATION.DELETE &&
358
358
  Schema.isSchema(change.previousValue)
359
359
  ) {
360
- const childRefId = (change.previousValue as Ref)[$refId];
360
+ const childRefId = (change.previousValue as Ref)[$refId]!;
361
361
  const deleteCallbacks = this.callbacks[childRefId]?.[OPERATION.DELETE];
362
362
  if (deleteCallbacks) {
363
363
  for (let j = deleteCallbacks.length - 1; j >= 0; j--) {
@@ -522,8 +522,10 @@ export const Callbacks = {
522
522
  return getDecoderStateCallbacks(roomOrDecoder);
523
523
 
524
524
  } else if ('decoder' in roomOrDecoder.serializer) {
525
- return getDecoderStateCallbacks(roomOrDecoder.serializer.decoder);
525
+ return getDecoderStateCallbacks((roomOrDecoder.serializer as { decoder: Decoder<T> }).decoder);
526
526
  }
527
+
528
+ throw new Error('Invalid room or decoder');
527
529
  },
528
530
 
529
531
  getRawChanges(decoder: Decoder, callback: (changes: DataChange[]) => void) {
@@ -442,8 +442,19 @@ export class Encoder<T extends Schema = any> {
442
442
  // view.changes with the stream-link ADD + element-field ADDs.
443
443
  this._emitStreamPriority(view);
444
444
 
445
- // encode visibility-triggered changes collected by view.add()
446
- for (const [refId, changes] of view.changes) {
445
+ //
446
+ // `view.changes` Map insertion order IS topological order:
447
+ // - `view.add` walks the parent chain to root via `addParentOf`
448
+ // (depth-first ancestor-first), inserting every ancestor's
449
+ // entry before the descendant's.
450
+ // - `view.remove` calls `_touchAncestorsOf` before its own
451
+ // write to insert any missing ancestors at the front of the
452
+ // chain — empty entries that get skipped by the size==0
453
+ // check below but establish Map position.
454
+ // No per-encode topo sort needed.
455
+ //
456
+ for (const refId of view.changes.keys()) {
457
+ const changes = view.changes.get(refId);
447
458
  const changeTree: ChangeTree = this.root.changeTrees[refId];
448
459
 
449
460
  if (changeTree === undefined) {
@@ -498,13 +498,28 @@ export class StateView {
498
498
  // view must have all "changeTree" parent tree
499
499
  this.markVisible(changeTree);
500
500
 
501
- // add parent's parent
501
+ // Recurse all the way to the root regardless of whether the
502
+ // parent is filtered. Walking the full chain is what makes
503
+ // `view.changes` topologically ordered by construction — any
504
+ // filtered ancestor up the chain is touched here, before the
505
+ // descendant's entry. The actual entry-write below is gated
506
+ // on `hasFilteredFields` so non-filtered ancestors don't
507
+ // emit redundant wire bytes (the decoder already knows them
508
+ // via the shared encode pass). Marking them visible is
509
+ // still useful: it makes this short-circuit fire on the next
510
+ // `view.add` instead of re-walking the chain.
502
511
  const parentChangeTree: ChangeTree = changeTree.parent?.[$changes];
503
- if (parentChangeTree && parentChangeTree.hasFilteredFields) {
512
+ if (parentChangeTree) {
504
513
  this.addParentOf(changeTree, tag);
505
514
  }
506
515
  }
507
516
 
517
+ // Skip the entry-write for non-filtered ancestors: their refIds
518
+ // are already known to the decoder through the shared pass, and
519
+ // an extra ADD on a non-filtered field's index would only emit
520
+ // bytes for a no-op (`value === previousValue` on the decoder).
521
+ if (!changeTree.hasFilteredFields) return;
522
+
508
523
  // add parent's tag properties
509
524
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
510
525
  let changes = this.changes.get(changeTree.ref[$refId]);
@@ -519,6 +534,46 @@ export class StateView {
519
534
  }
520
535
  }
521
536
 
537
+ /**
538
+ * Walk `tree`'s parent chain to root and insert an empty entry into
539
+ * `view.changes` for any ancestor not already present. Empty entries
540
+ * are skipped by `encodeView` (`changes.size === 0` continue), so no
541
+ * wire bytes are emitted — but the Map's insertion order now puts
542
+ * each ancestor BEFORE the descendant entry that the caller is about
543
+ * to write. Combined with `addParentOf`'s full-recursion walk on
544
+ * `view.add`, this preserves the global invariant that
545
+ * `view.changes` iteration order is topological.
546
+ *
547
+ * Iterative (not recursive) so the stack is bounded by tree depth
548
+ * regardless of call patterns. Stops the walk as soon as it hits an
549
+ * ancestor that's already in `view.changes` — at that point the
550
+ * remainder of the chain is guaranteed to also be present (invariant
551
+ * upheld by every prior caller).
552
+ */
553
+ private _touchAncestorsOf(tree: ChangeTree): void {
554
+ let cursor = tree.parent?.[$changes] as ChangeTree | undefined;
555
+ if (cursor === undefined) return;
556
+
557
+ // Collect the missing prefix of the chain, deepest-first. Only
558
+ // FILTERED ancestors need entries — non-filtered ones never
559
+ // appear in `view.changes` (mirrors the addParentOf gate), so
560
+ // they don't need a Map slot reserved either.
561
+ const stack: ChangeTree[] = [];
562
+ while (cursor !== undefined) {
563
+ if (cursor.hasFilteredFields) {
564
+ const refId = cursor.ref[$refId];
565
+ if (this.changes.has(refId)) break;
566
+ stack.push(cursor);
567
+ }
568
+ cursor = cursor.parent?.[$changes] as ChangeTree | undefined;
569
+ }
570
+
571
+ // Insert root-first so Map order is topological.
572
+ for (let i = stack.length - 1; i >= 0; i--) {
573
+ this.changes.set(stack[i].ref[$refId], new Map());
574
+ }
575
+ }
576
+
522
577
  remove(obj: Ref, tag?: number): this; // hide _isClear parameter from public API
523
578
  remove(obj: Ref, tag?: number, _isClear?: boolean): this;
524
579
  remove(obj: Ref, tag: number = DEFAULT_VIEW_TAG, _isClear: boolean = false): this {
@@ -594,6 +649,12 @@ export class StateView {
594
649
 
595
650
  const refId = ref[$refId];
596
651
 
652
+ // Pre-insert any missing ancestors into view.changes so the Map's
653
+ // iteration order stays topological — the entries we're about to
654
+ // write (either on this obj, or on its parent collection below)
655
+ // must come AFTER every ancestor in the chain on the wire.
656
+ this._touchAncestorsOf(changeTree);
657
+
597
658
  let changes = this.changes.get(refId);
598
659
  if (changes === undefined) {
599
660
  changes = new Map<number, OPERATION>();
@@ -4,6 +4,7 @@
4
4
  * the parent field's annotation + the parent tree's own state.
5
5
  */
6
6
  import { Metadata } from "../../Metadata.js";
7
+ import { DEFAULT_VIEW_TAG } from "../../annotations.js";
7
8
  import {
8
9
  $changes, $childType,
9
10
  $staticFieldIndexes, $streamFieldIndexes,
@@ -206,12 +207,25 @@ export function checkInheritedFlags(tree: ChangeTree, parent: Ref, parentIndex:
206
207
  const refType = Metadata.isValidInstance(tree.ref)
207
208
  ? tree.ref.constructor
208
209
  : (tree.ref as any)[$childType];
210
+ // #218: nested Schema fields inherit visibility from a @view-gated
211
+ // parent regardless of whether the parent is a collection. The
212
+ // `parentIsCollection` constraint that used to live here blocked
213
+ // nested-Schema-field-of-@view-tagged-Schema from sharing visibility,
214
+ // forcing users to wrap the child in an ArraySchema as a workaround.
215
+ //
216
+ // #226 (4.0.25): items inside a non-default-tag `@view(N)` collection
217
+ // also inherit visibility from the parent collection, so items
218
+ // pushed/set after `view.add(state, N)` show up automatically.
219
+ // Default-tag `@view()` collections keep per-item gating —
220
+ // `view.add(item)` is still required to opt each one in.
221
+ // The `parentMetadata[parentIndex].tag` access is safe inside the
222
+ // `fieldHasViewTag` short-circuit (the metadata entry and its `tag`
223
+ // are guaranteed to exist when that flag is set).
209
224
  tree.isVisibilitySharedWithParent = (
210
225
  parentChangeTree.isFiltered
211
226
  && typeof refType !== "string"
212
- && !fieldHasViewTag
213
227
  && !fieldHasStream
214
- && parentIsCollection
228
+ && (!fieldHasViewTag || (parentIsCollection && parentMetadata[parentIndex].tag !== DEFAULT_VIEW_TAG))
215
229
  );
216
230
  }
217
231
  }