From 209a516c2f36bd4211543f72ea09d79bb6758926 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Sat, 21 Feb 2026 00:16:29 +0000 Subject: [PATCH 01/11] Add inheritVisibility tag that lets a Schema class have the same visibility as its parent --- src/encoder/ChangeTree.ts | 6 ++--- src/index.ts | 1 + src/types/symbols.ts | 6 +++++ src/types/utils.ts | 13 +++++++++- test/Schema.test.ts | 50 ++++++++++++++++++++++++++++++++++++++- test/Schema.ts | 22 +++++++++++++++-- 6 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/encoder/ChangeTree.ts b/src/encoder/ChangeTree.ts index 268e3705..f4d06bd5 100644 --- a/src/encoder/ChangeTree.ts +++ b/src/encoder/ChangeTree.ts @@ -1,6 +1,6 @@ import { OPERATION } from "../encoding/spec.js"; import { Schema } from "../Schema.js"; -import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refId, $refTypeFieldIndexes, $viewFieldIndexes, type $deleteByIndex } from "../types/symbols.js"; +import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refId, $refTypeFieldIndexes, $viewFieldIndexes, type $deleteByIndex, $inheritVisibility } from "../types/symbols.js"; import type { MapSchema } from "../types/custom/MapSchema.js"; import type { ArraySchema } from "../types/custom/ArraySchema.js"; @@ -568,7 +568,7 @@ export class ChangeTree { || fieldHasViewTag; // - // "isFiltered" may not be imedialely available during `change()` due to the instance not being attached to the root yet. + // "isFiltered" may not be immediately available during `change()` due to the instance not being attached to the root yet. // when it's available, we need to enqueue the "changes" changeset into the "filteredChanges" changeset. // if (this.isFiltered) { @@ -577,7 +577,7 @@ export class ChangeTree { parentChangeTree.isFiltered && typeof (refType) !== "string" && !fieldHasViewTag && - parentIsCollection + (parentIsCollection || (refType as any)?.[$inheritVisibility] === true) ); if (!this.filteredChanges) { diff --git a/src/index.ts b/src/index.ts index 31178c9b..1056bdc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ registerType("collection", { constructor: CollectionSchema, }); // Utils export { dumpChanges } from "./utils.js"; +export { inheritVisibility } from "./types/utils.js"; // Encoder / Decoder export { $track, $encoder, $decoder, $filter, $getByIndex, $deleteByIndex, $changes, $childType, $refId } from "./types/symbols.js"; diff --git a/src/types/symbols.ts b/src/types/symbols.ts index 6e2ebad1..63d34fcc 100644 --- a/src/types/symbols.ts +++ b/src/types/symbols.ts @@ -30,6 +30,12 @@ export const $onEncodeEnd = '~onEncodeEnd'; */ export const $onDecodeEnd = "~onDecodeEnd"; +/** + * Used to mark that a Schema class should be visible whenever it's parent is visible, even if it doesn't have any view tags itself. + * This is used for "nested" Schema classes that are only used as fields of other Schema classes. + */ +export const $inheritVisibility = '~inheritVisibility'; + /** * Metadata */ diff --git a/src/types/utils.ts b/src/types/utils.ts index da42b877..7a451457 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,3 +1,6 @@ +import { Schema } from '../Schema.js'; +import { $inheritVisibility } from './symbols.js'; + export function spliceOne(arr: any[], index: number): boolean { // manually splice an array if (index === -1 || index >= arr.length) { @@ -13,4 +16,12 @@ export function spliceOne(arr: any[], index: number): boolean { arr.length = len; return true; -} \ No newline at end of file +} + +/** Mark a Schema class so its instances share visibility with their Schema parent, + * the same way that ArraySchema (etc) items do. */ +export function inheritVisibility Schema>(constructor: T): T { + return class extends constructor { + inheritVisibility: true; + }; +} diff --git a/test/Schema.test.ts b/test/Schema.test.ts index 382107d1..49c89689 100644 --- a/test/Schema.test.ts +++ b/test/Schema.test.ts @@ -7,7 +7,7 @@ // import "core-js"; import * as assert from "assert"; -import { State, Player, DeepState, DeepMap, DeepChild, Position, DeepEntity, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder } from "./Schema"; +import { State, Player, DeepState, DeepMap, DeepChild, Position, DeepEntity, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, Another, InheritanceParent } from "./Schema"; import { Schema, ArraySchema, MapSchema, type, Metadata, $changes, Encoder, Decoder, SetSchema, schema, ToJSON, $refId } from "../src"; import { getNormalizedType } from "../src/Metadata"; @@ -1017,6 +1017,54 @@ describe("Type: Schema", () => { assert.deepStrictEqual(decodedState.mapOfNumbers.toJSON(), { 'zero': 0, 'one': 1, 'two': 2, 'three': 3 }); }); + it("should not encode nested Schema that wasn't initially assigned", () => { + const state = new InheritanceParent(); + const encoded = state.encode(); + + const decodedState = new InheritanceParent(); + decodedState.decode(encoded); + + assert.strictEqual(decodedState.position, undefined); + + /** + * Lets encode an assignment now: existing behaviour will encode the position, but since it wasn't assigned when we first decoded, changes will be ignored. + */ + + state.position = new Position(1, 2, 3); + + const serializedChanges = state.encode(); + + decodedState.decode(serializedChanges); + assert.notStrictEqual(decodedState.position, undefined); + assert.strictEqual(decodedState.position.x, undefined); + assert.strictEqual(decodedState.position.y, undefined); + assert.strictEqual(decodedState.position.z, undefined); + }); + + it("should encode nested Schema with @inheritVisibility that wasn't initially assigned", () => { + const state = new InheritanceParent(); + const encoded = state.encode(); + + const decodedState = new InheritanceParent(); + decodedState.decode(encoded); + + assert.strictEqual(decodedState.inheritedPosition, undefined); + + /** + * Lets encode an assignment now: since it's marked with @inheritVisibility, it should be encoded and decoded even if it wasn't assigned when we first decoded the state. + */ + + state.inheritedPosition = new Position(1, 2, 3); + + const serializedChanges = state.encode(); + + decodedState.decode(serializedChanges); + assert.notStrictEqual(decodedState.inheritedPosition, undefined); + assert.strictEqual(decodedState.inheritedPosition.x, 1); + assert.strictEqual(decodedState.inheritedPosition.y, 2); + assert.strictEqual(decodedState.inheritedPosition.z, 3); + }); + describe("no changes", () => { it("empty state", () => { const state = new State(); diff --git a/test/Schema.ts b/test/Schema.ts index 1d12c31f..78192380 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -1,6 +1,6 @@ import * as assert from "assert"; -import { Schema, type, ArraySchema, MapSchema, Reflection, Iterator, StateView } from "../src"; +import { Schema, type, ArraySchema, MapSchema, Reflection, Iterator, StateView, inheritVisibility } from "../src"; import { Decoder } from "../src/decoder/Decoder"; import { Encoder } from "../src/encoder/Encoder"; import { CallbackProxy, getDecoderStateCallbacks, SchemaCallbackProxy } from "../src/decoder/strategy/getDecoderStateCallbacks"; @@ -249,10 +249,28 @@ export class Position extends Schema { } } +@inheritVisibility +export class InheritedPosition extends Schema { + @type("float32") x: number; + @type("float32") y: number; + @type("float32") z: number; + + constructor(x: number, y: number, z: number) { + super(); + this.x = x; + this.y = y; + this.z = z; + } +} + +export class InheritanceParent extends Schema { + @type(Position) position: Position | undefined = undefined; + @type(InheritedPosition) inheritedPosition: InheritedPosition | undefined = undefined; +} + export class Another extends Schema { @type(Position) position: Position = new Position(0, 0, 0); } - export class DeepEntity extends Schema { @type("string") name: string; @type(Another) another: Another = new Another(); From 94ef511405e8d29501da8b70bb4bed7143fa9b4e Mon Sep 17 00:00:00 2001 From: FTWinston Date: Sat, 21 Feb 2026 00:33:32 +0000 Subject: [PATCH 02/11] Change prepublishOnly to prepare, so fork can be used directly --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3eebe1e0..2485c286 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "generate-test-10": "bin/schema-codegen test-external/Callbacks.ts --namespace SchemaTest.Callbacks --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/Callbacks", "generate-test-11": "bin/schema-codegen test-external/MapSchemaMoveNullifyType.ts --namespace SchemaTest.MapSchemaMoveNullifyType --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/MapSchemaMoveNullifyType", "generate-test-12": "bin/schema-codegen test-external/ArraySchemaClear --namespace SchemaTest.ArraySchemaClear --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/ArraySchemaClear", - "prepublishOnly": "npm run build" + "prepare": "npm run build" }, "files": [ "src", From faa473942409abad068937948d2a23b0ad659027 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Sun, 22 Feb 2026 22:17:15 +0000 Subject: [PATCH 03/11] Fix typo in inheritVisibility function --- src/types/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/utils.ts b/src/types/utils.ts index 7a451457..16cee049 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -22,6 +22,6 @@ export function spliceOne(arr: any[], index: number): boolean { * the same way that ArraySchema (etc) items do. */ export function inheritVisibility Schema>(constructor: T): T { return class extends constructor { - inheritVisibility: true; + [$inheritVisibility]: true; }; } From f6c21753c94d7bf505c8272dbf645a237ffb98df Mon Sep 17 00:00:00 2001 From: FTWinston Date: Sun, 22 Feb 2026 22:29:55 +0000 Subject: [PATCH 04/11] Return original class, not a subclass --- src/types/utils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/types/utils.ts b/src/types/utils.ts index 16cee049..201bece9 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -21,7 +21,6 @@ export function spliceOne(arr: any[], index: number): boolean { /** Mark a Schema class so its instances share visibility with their Schema parent, * the same way that ArraySchema (etc) items do. */ export function inheritVisibility Schema>(constructor: T): T { - return class extends constructor { - [$inheritVisibility]: true; - }; + (constructor as any)[$inheritVisibility] = true; + return constructor; } From b27a89752319760058ed74eb1c973b727bfdcb96 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 23 Feb 2026 00:08:22 +0000 Subject: [PATCH 05/11] Trying to get a test of the original "failing to synchronize" behaviour to run --- test/Schema.test.ts | 65 ++++++++++++++++++++++++++++----------------- test/Schema.ts | 8 ++++-- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/test/Schema.test.ts b/test/Schema.test.ts index 49c89689..abcd5735 100644 --- a/test/Schema.test.ts +++ b/test/Schema.test.ts @@ -7,8 +7,8 @@ // import "core-js"; import * as assert from "assert"; -import { State, Player, DeepState, DeepMap, DeepChild, Position, DeepEntity, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, Another, InheritanceParent } from "./Schema"; -import { Schema, ArraySchema, MapSchema, type, Metadata, $changes, Encoder, Decoder, SetSchema, schema, ToJSON, $refId } from "../src"; +import { State, Player, DeepState, DeepMap, DeepChild, Position, DeepEntity, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, Another, InheritanceParent, InheritedPosition, InheritanceRoot } from "./Schema"; +import { Schema, ArraySchema, MapSchema, type, Metadata, $changes, Encoder, Decoder, SetSchema, schema, ToJSON, $refId, StateView } from "../src"; import { getNormalizedType } from "../src/Metadata"; describe("Type: Schema", () => { @@ -1018,51 +1018,68 @@ describe("Type: Schema", () => { }); it("should not encode nested Schema that wasn't initially assigned", () => { - const state = new InheritanceParent(); + const state = new InheritanceRoot(); + const view = new StateView(); + view.add(state.array); + const encoded = state.encode(); - const decodedState = new InheritanceParent(); + const decodedState = new InheritanceRoot(); decodedState.decode(encoded); - assert.strictEqual(decodedState.position, undefined); + assert.strictEqual(decodedState.array.length, 0); - /** - * Lets encode an assignment now: existing behaviour will encode the position, but since it wasn't assigned when we first decoded, changes will be ignored. - */ + const arrayItem = new InheritanceParent(); - state.position = new Position(1, 2, 3); + state.array.push(arrayItem); + arrayItem.standardPosition = new Position(1, 2, 3); + + /** + * Encode an assignment of an InheritanceParent into the array: since no array item was assigned when we first decoded, fields on the array item's "standard" child will be ignored. + */ const serializedChanges = state.encode(); decodedState.decode(serializedChanges); - assert.notStrictEqual(decodedState.position, undefined); - assert.strictEqual(decodedState.position.x, undefined); - assert.strictEqual(decodedState.position.y, undefined); - assert.strictEqual(decodedState.position.z, undefined); + assert.strictEqual(decodedState.array.length, 1); + assert.notStrictEqual(decodedState.array[0], undefined); + assert.notStrictEqual(decodedState.array[0].standardPosition, undefined); + assert.strictEqual(decodedState.array[0].standardPosition.x, undefined); + assert.strictEqual(decodedState.array[0].standardPosition.y, undefined); + assert.strictEqual(decodedState.array[0].standardPosition.z, undefined); }); it("should encode nested Schema with @inheritVisibility that wasn't initially assigned", () => { - const state = new InheritanceParent(); + const state = new InheritanceRoot(); + const view = new StateView(); + view.add(state.array); + const encoded = state.encode(); - const decodedState = new InheritanceParent(); + const decodedState = new InheritanceRoot(); decodedState.decode(encoded); - assert.strictEqual(decodedState.inheritedPosition, undefined); + assert.strictEqual(decodedState.array.length, 0); - /** - * Lets encode an assignment now: since it's marked with @inheritVisibility, it should be encoded and decoded even if it wasn't assigned when we first decoded the state. - */ + const arrayItem = new InheritanceParent(); - state.inheritedPosition = new Position(1, 2, 3); + state.array.push(arrayItem); + arrayItem.inheritingPosition = new InheritedPosition(1, 2, 3); + + /** + * Encode an assignment of an InheritanceParent into the array: even though no array item was assigned when we first decoded, fields on the array item's "inheriting" child will be encoded and decoded, + * as that child is marked with @inheritVisibility. + */ const serializedChanges = state.encode(); decodedState.decode(serializedChanges); - assert.notStrictEqual(decodedState.inheritedPosition, undefined); - assert.strictEqual(decodedState.inheritedPosition.x, 1); - assert.strictEqual(decodedState.inheritedPosition.y, 2); - assert.strictEqual(decodedState.inheritedPosition.z, 3); + assert.strictEqual(decodedState.array.length, 1); + assert.notStrictEqual(decodedState.array[0], undefined); + assert.notStrictEqual(decodedState.array[0].inheritingPosition, undefined); + assert.strictEqual(decodedState.array[0].inheritingPosition.x, 1); + assert.strictEqual(decodedState.array[0].inheritingPosition.y, 2); + assert.strictEqual(decodedState.array[0].inheritingPosition.z, 3); }); describe("no changes", () => { diff --git a/test/Schema.ts b/test/Schema.ts index 78192380..e52e62d4 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -264,8 +264,12 @@ export class InheritedPosition extends Schema { } export class InheritanceParent extends Schema { - @type(Position) position: Position | undefined = undefined; - @type(InheritedPosition) inheritedPosition: InheritedPosition | undefined = undefined; + @type(Position) standardPosition: Position | undefined = undefined; + @type(InheritedPosition) inheritingPosition: InheritedPosition | undefined = undefined; +} + +export class InheritanceRoot extends Schema { + @type([InheritanceParent]) array = new ArraySchema(); } export class Another extends Schema { From 67b8fd091b54827aac3eac1ffb853a686d9ce8b9 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 23 Feb 2026 14:17:20 +0000 Subject: [PATCH 06/11] Update tests to use createClientWithView and encodeMultiple --- test/Schema.test.ts | 81 ++++++++++++++++++++------------------------- test/Schema.ts | 8 ++--- 2 files changed, 40 insertions(+), 49 deletions(-) diff --git a/test/Schema.test.ts b/test/Schema.test.ts index abcd5735..d6dc174e 100644 --- a/test/Schema.test.ts +++ b/test/Schema.test.ts @@ -7,8 +7,8 @@ // import "core-js"; import * as assert from "assert"; -import { State, Player, DeepState, DeepMap, DeepChild, Position, DeepEntity, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, Another, InheritanceParent, InheritedPosition, InheritanceRoot } from "./Schema"; -import { Schema, ArraySchema, MapSchema, type, Metadata, $changes, Encoder, Decoder, SetSchema, schema, ToJSON, $refId, StateView } from "../src"; +import { State, Player, DeepState, DeepMap, DeepChild, Position, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, InheritedPosition, InheritanceRoot, createClientWithView, encodeMultiple } from "./Schema"; +import { Schema, ArraySchema, MapSchema, type, Metadata, $changes, Encoder, Decoder, SetSchema, schema, ToJSON, $refId } from "../src"; import { getNormalizedType } from "../src/Metadata"; describe("Type: Schema", () => { @@ -1019,67 +1019,58 @@ describe("Type: Schema", () => { it("should not encode nested Schema that wasn't initially assigned", () => { const state = new InheritanceRoot(); - const view = new StateView(); - view.add(state.array); - - const encoded = state.encode(); - - const decodedState = new InheritanceRoot(); - decodedState.decode(encoded); - - assert.strictEqual(decodedState.array.length, 0); + const encoder = getEncoder(state); - const arrayItem = new InheritanceParent(); + const client = createClientWithView(state); + client.view.add(state.parent); - state.array.push(arrayItem); + // Initial encode: child property is undefined + encodeMultiple(encoder, state, [client]); - arrayItem.standardPosition = new Position(1, 2, 3); + // Assign a child Schema that does NOT use @inheritVisibility + state.parent.standardChild = new Position(1, 2, 3); /** - * Encode an assignment of an InheritanceParent into the array: since no array item was assigned when we first decoded, fields on the array item's "standard" child will be ignored. + * Encode an assignment of a child field: + * since the "standardChild" field (Position) does not use + * @inheritVisibility, its fields are not visible to the client + * even though the parent InheritanceParent is visible. */ - const serializedChanges = state.encode(); + encodeMultiple(encoder, state, [client]); - decodedState.decode(serializedChanges); - assert.strictEqual(decodedState.array.length, 1); - assert.notStrictEqual(decodedState.array[0], undefined); - assert.notStrictEqual(decodedState.array[0].standardPosition, undefined); - assert.strictEqual(decodedState.array[0].standardPosition.x, undefined); - assert.strictEqual(decodedState.array[0].standardPosition.y, undefined); - assert.strictEqual(decodedState.array[0].standardPosition.z, undefined); + assert.notStrictEqual(client.state.parent, undefined); + assert.notStrictEqual(client.state.parent.standardChild, undefined); + assert.strictEqual(client.state.parent.standardChild.x, undefined); + assert.strictEqual(client.state.parent.standardChild.y, undefined); + assert.strictEqual(client.state.parent.standardChild.z, undefined); }); it("should encode nested Schema with @inheritVisibility that wasn't initially assigned", () => { const state = new InheritanceRoot(); - const view = new StateView(); - view.add(state.array); - - const encoded = state.encode(); - - const decodedState = new InheritanceRoot(); - decodedState.decode(encoded); - - assert.strictEqual(decodedState.array.length, 0); + const encoder = getEncoder(state); - const arrayItem = new InheritanceParent(); + const client = createClientWithView(state); + client.view.add(state.parent); - state.array.push(arrayItem); + // Initial encode: child property is undefined + encodeMultiple(encoder, state, [client]); - arrayItem.inheritingPosition = new InheritedPosition(1, 2, 3); + // Assign a child Schema that uses @inheritVisibility + state.parent.inheritingChild = new InheritedPosition(1, 2, 3); /** - * Encode an assignment of an InheritanceParent into the array: even though no array item was assigned when we first decoded, fields on the array item's "inheriting" child will be encoded and decoded, - * as that child is marked with @inheritVisibility. + * Encode an assignment of a child field: + * the child "inheritingChild" field (InheritedPosition) is marked with + * @inheritVisibility, so it shares visibility with its parent and + * its fields are encoded for the client. */ - const serializedChanges = state.encode(); + encodeMultiple(encoder, state, [client]); - decodedState.decode(serializedChanges); - assert.strictEqual(decodedState.array.length, 1); - assert.notStrictEqual(decodedState.array[0], undefined); - assert.notStrictEqual(decodedState.array[0].inheritingPosition, undefined); - assert.strictEqual(decodedState.array[0].inheritingPosition.x, 1); - assert.strictEqual(decodedState.array[0].inheritingPosition.y, 2); - assert.strictEqual(decodedState.array[0].inheritingPosition.z, 3); + assert.notStrictEqual(client.state.parent, undefined); + assert.notStrictEqual(client.state.parent.inheritingChild, undefined); + assert.strictEqual(client.state.parent.inheritingChild.x, 1); + assert.strictEqual(client.state.parent.inheritingChild.y, 2); + assert.strictEqual(client.state.parent.inheritingChild.z, 3); }); describe("no changes", () => { diff --git a/test/Schema.ts b/test/Schema.ts index e52e62d4..17c5fd04 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -1,6 +1,6 @@ import * as assert from "assert"; -import { Schema, type, ArraySchema, MapSchema, Reflection, Iterator, StateView, inheritVisibility } from "../src"; +import { Schema, type, ArraySchema, MapSchema, Reflection, Iterator, StateView, inheritVisibility, view } from "../src"; import { Decoder } from "../src/decoder/Decoder"; import { Encoder } from "../src/encoder/Encoder"; import { CallbackProxy, getDecoderStateCallbacks, SchemaCallbackProxy } from "../src/decoder/strategy/getDecoderStateCallbacks"; @@ -264,12 +264,12 @@ export class InheritedPosition extends Schema { } export class InheritanceParent extends Schema { - @type(Position) standardPosition: Position | undefined = undefined; - @type(InheritedPosition) inheritingPosition: InheritedPosition | undefined = undefined; + @type(Position) standardChild: Position | undefined = undefined; + @type(InheritedPosition) inheritingChild: InheritedPosition | undefined = undefined; } export class InheritanceRoot extends Schema { - @type([InheritanceParent]) array = new ArraySchema(); + @view() @type(InheritanceParent) parent = new InheritanceParent(); } export class Another extends Schema { From 2de73c14beb5bff8a9b293d2bcf9a277685188ed Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 23 Feb 2026 14:21:28 +0000 Subject: [PATCH 07/11] Add a test showing that @view alone doesn't resolve this issue --- test/Schema.test.ts | 28 ++++++++++++++++++++++++++++ test/Schema.ts | 1 + 2 files changed, 29 insertions(+) diff --git a/test/Schema.test.ts b/test/Schema.test.ts index d6dc174e..8105b387 100644 --- a/test/Schema.test.ts +++ b/test/Schema.test.ts @@ -1045,6 +1045,34 @@ describe("Type: Schema", () => { assert.strictEqual(client.state.parent.standardChild.z, undefined); }); + it("should not encode nested Schema with @view that wasn't initially assigned", () => { + const state = new InheritanceRoot(); + const encoder = getEncoder(state); + + const client = createClientWithView(state); + client.view.add(state.parent); + + // Initial encode: child property is undefined + encodeMultiple(encoder, state, [client]); + + // Assign a child Schema that uses @view + state.parent.viewChild = new Position(1, 2, 3); + + /** + * Encode an assignment of a child field: + * the child "viewChild" field (Position) is marked with @view, + * so it shares visibility with its parent and + * its fields are encoded for the client. + */ + encodeMultiple(encoder, state, [client]); + + assert.notStrictEqual(client.state.parent, undefined); + assert.notStrictEqual(client.state.parent.viewChild, undefined); + assert.strictEqual(client.state.parent.viewChild.x, undefined); + assert.strictEqual(client.state.parent.viewChild.y, undefined); + assert.strictEqual(client.state.parent.viewChild.z, undefined); + }); + it("should encode nested Schema with @inheritVisibility that wasn't initially assigned", () => { const state = new InheritanceRoot(); const encoder = getEncoder(state); diff --git a/test/Schema.ts b/test/Schema.ts index 17c5fd04..9f80eb2f 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -265,6 +265,7 @@ export class InheritedPosition extends Schema { export class InheritanceParent extends Schema { @type(Position) standardChild: Position | undefined = undefined; + @view() @type(Position) viewChild: Position | undefined = undefined; @type(InheritedPosition) inheritingChild: InheritedPosition | undefined = undefined; } From b47eddd2dcf21a2ac096ace15bde33dd7e7ffb52 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 23 Feb 2026 23:18:49 +0000 Subject: [PATCH 08/11] Undo whitespace change --- test/Schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Schema.ts b/test/Schema.ts index 9f80eb2f..e425b71d 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -276,6 +276,7 @@ export class InheritanceRoot extends Schema { export class Another extends Schema { @type(Position) position: Position = new Position(0, 0, 0); } + export class DeepEntity extends Schema { @type("string") name: string; @type(Another) another: Another = new Another(); From 09cb73272ff1e22d82c3ab31759a8bf2853f06bf Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 23 Feb 2026 23:35:16 +0000 Subject: [PATCH 09/11] Add test demonstrating ArraySchema workaround --- test/Schema.test.ts | 27 +++++++++++++++++++++++++++ test/Schema.ts | 1 + 2 files changed, 28 insertions(+) diff --git a/test/Schema.test.ts b/test/Schema.test.ts index 8105b387..e9a3900c 100644 --- a/test/Schema.test.ts +++ b/test/Schema.test.ts @@ -1073,6 +1073,33 @@ describe("Type: Schema", () => { assert.strictEqual(client.state.parent.viewChild.z, undefined); }); + it("should encode nested Schema wrapped in ArraySchema that wasn't initially assigned", () => { + const state = new InheritanceRoot(); + const encoder = getEncoder(state); + + const client = createClientWithView(state); + client.view.add(state.parent); + + // Initial encode: child property is undefined + encodeMultiple(encoder, state, [client]); + + // Assign a child Schema wrapped in an ArraySchema, to demonstrate this workaround. + state.parent.arrayChild.push(new Position(1, 2, 3)); + + /** + * Encode an assignment of a child field wrapped in an ArraySchema + * the child "arrayChild" field (Position), being in an ArraySchema, + * shares visibility with its parent and its fields are encoded for the client. + */ + encodeMultiple(encoder, state, [client]); + + assert.notStrictEqual(client.state.parent, undefined); + assert.strictEqual(client.state.parent.arrayChild.length, 1); + assert.strictEqual(client.state.parent.arrayChild[0].x, 1); + assert.strictEqual(client.state.parent.arrayChild[0].y, 2); + assert.strictEqual(client.state.parent.arrayChild[0].z, 3); + }); + it("should encode nested Schema with @inheritVisibility that wasn't initially assigned", () => { const state = new InheritanceRoot(); const encoder = getEncoder(state); diff --git a/test/Schema.ts b/test/Schema.ts index e425b71d..c387321c 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -266,6 +266,7 @@ export class InheritedPosition extends Schema { export class InheritanceParent extends Schema { @type(Position) standardChild: Position | undefined = undefined; @view() @type(Position) viewChild: Position | undefined = undefined; + @type([Position]) arrayChild: ArraySchema = new ArraySchema(); @type(InheritedPosition) inheritingChild: InheritedPosition | undefined = undefined; } From c1ce4827e103a7181f40a3dbb5b0729b215de96f Mon Sep 17 00:00:00 2001 From: FTWinston Date: Wed, 25 Feb 2026 10:45:57 +0000 Subject: [PATCH 10/11] Remove @inheritVisibility decorator, make its behaviour be the default --- src/encoder/ChangeTree.ts | 5 ++-- src/index.ts | 1 - src/types/symbols.ts | 6 ----- src/types/utils.ts | 12 +-------- test/Schema.test.ts | 56 +++++++++------------------------------ test/Schema.ts | 17 +----------- 6 files changed, 17 insertions(+), 80 deletions(-) diff --git a/src/encoder/ChangeTree.ts b/src/encoder/ChangeTree.ts index f4d06bd5..7330957a 100644 --- a/src/encoder/ChangeTree.ts +++ b/src/encoder/ChangeTree.ts @@ -1,6 +1,6 @@ import { OPERATION } from "../encoding/spec.js"; import { Schema } from "../Schema.js"; -import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refId, $refTypeFieldIndexes, $viewFieldIndexes, type $deleteByIndex, $inheritVisibility } from "../types/symbols.js"; +import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refId, $refTypeFieldIndexes, $viewFieldIndexes, type $deleteByIndex } from "../types/symbols.js"; import type { MapSchema } from "../types/custom/MapSchema.js"; import type { ArraySchema } from "../types/custom/ArraySchema.js"; @@ -576,8 +576,7 @@ export class ChangeTree { this.isVisibilitySharedWithParent = ( parentChangeTree.isFiltered && typeof (refType) !== "string" && - !fieldHasViewTag && - (parentIsCollection || (refType as any)?.[$inheritVisibility] === true) + !fieldHasViewTag ); if (!this.filteredChanges) { diff --git a/src/index.ts b/src/index.ts index 1056bdc1..31178c9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,6 @@ registerType("collection", { constructor: CollectionSchema, }); // Utils export { dumpChanges } from "./utils.js"; -export { inheritVisibility } from "./types/utils.js"; // Encoder / Decoder export { $track, $encoder, $decoder, $filter, $getByIndex, $deleteByIndex, $changes, $childType, $refId } from "./types/symbols.js"; diff --git a/src/types/symbols.ts b/src/types/symbols.ts index 63d34fcc..6e2ebad1 100644 --- a/src/types/symbols.ts +++ b/src/types/symbols.ts @@ -30,12 +30,6 @@ export const $onEncodeEnd = '~onEncodeEnd'; */ export const $onDecodeEnd = "~onDecodeEnd"; -/** - * Used to mark that a Schema class should be visible whenever it's parent is visible, even if it doesn't have any view tags itself. - * This is used for "nested" Schema classes that are only used as fields of other Schema classes. - */ -export const $inheritVisibility = '~inheritVisibility'; - /** * Metadata */ diff --git a/src/types/utils.ts b/src/types/utils.ts index 201bece9..da42b877 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,6 +1,3 @@ -import { Schema } from '../Schema.js'; -import { $inheritVisibility } from './symbols.js'; - export function spliceOne(arr: any[], index: number): boolean { // manually splice an array if (index === -1 || index >= arr.length) { @@ -16,11 +13,4 @@ export function spliceOne(arr: any[], index: number): boolean { arr.length = len; return true; -} - -/** Mark a Schema class so its instances share visibility with their Schema parent, - * the same way that ArraySchema (etc) items do. */ -export function inheritVisibility Schema>(constructor: T): T { - (constructor as any)[$inheritVisibility] = true; - return constructor; -} +} \ No newline at end of file diff --git a/test/Schema.test.ts b/test/Schema.test.ts index e9a3900c..ccc5277e 100644 --- a/test/Schema.test.ts +++ b/test/Schema.test.ts @@ -7,7 +7,7 @@ // import "core-js"; import * as assert from "assert"; -import { State, Player, DeepState, DeepMap, DeepChild, Position, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, InheritedPosition, InheritanceRoot, createClientWithView, encodeMultiple } from "./Schema"; +import { State, Player, DeepState, DeepMap, DeepChild, Position, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, InheritanceRoot, createClientWithView, encodeMultiple } from "./Schema"; import { Schema, ArraySchema, MapSchema, type, Metadata, $changes, Encoder, Decoder, SetSchema, schema, ToJSON, $refId } from "../src"; import { getNormalizedType } from "../src/Metadata"; @@ -1017,7 +1017,7 @@ describe("Type: Schema", () => { assert.deepStrictEqual(decodedState.mapOfNumbers.toJSON(), { 'zero': 0, 'one': 1, 'two': 2, 'three': 3 }); }); - it("should not encode nested Schema that wasn't initially assigned", () => { + it("should encode nested Schema", () => { const state = new InheritanceRoot(); const encoder = getEncoder(state); @@ -1027,25 +1027,23 @@ describe("Type: Schema", () => { // Initial encode: child property is undefined encodeMultiple(encoder, state, [client]); - // Assign a child Schema that does NOT use @inheritVisibility + // Assign a child Schema instance. state.parent.standardChild = new Position(1, 2, 3); /** * Encode an assignment of a child field: - * since the "standardChild" field (Position) does not use - * @inheritVisibility, its fields are not visible to the client - * even though the parent InheritanceParent is visible. + * Its fields should be visible to the client, because it inherits visibility from its parent. */ encodeMultiple(encoder, state, [client]); assert.notStrictEqual(client.state.parent, undefined); assert.notStrictEqual(client.state.parent.standardChild, undefined); - assert.strictEqual(client.state.parent.standardChild.x, undefined); - assert.strictEqual(client.state.parent.standardChild.y, undefined); - assert.strictEqual(client.state.parent.standardChild.z, undefined); + assert.strictEqual(client.state.parent.standardChild.x, 1); + assert.strictEqual(client.state.parent.standardChild.y, 2); + assert.strictEqual(client.state.parent.standardChild.z, 3); }); - it("should not encode nested Schema with @view that wasn't initially assigned", () => { + it("should not encode nested Schema with @view", () => { const state = new InheritanceRoot(); const encoder = getEncoder(state); @@ -1061,8 +1059,8 @@ describe("Type: Schema", () => { /** * Encode an assignment of a child field: * the child "viewChild" field (Position) is marked with @view, - * so it shares visibility with its parent and - * its fields are encoded for the client. + * so it does not share visibility with its parent, and + * its fields are not encoded for the client. */ encodeMultiple(encoder, state, [client]); @@ -1073,7 +1071,7 @@ describe("Type: Schema", () => { assert.strictEqual(client.state.parent.viewChild.z, undefined); }); - it("should encode nested Schema wrapped in ArraySchema that wasn't initially assigned", () => { + it("should encode nested Schema wrapped in ArraySchema", () => { const state = new InheritanceRoot(); const encoder = getEncoder(state); @@ -1088,8 +1086,8 @@ describe("Type: Schema", () => { /** * Encode an assignment of a child field wrapped in an ArraySchema - * the child "arrayChild" field (Position), being in an ArraySchema, - * shares visibility with its parent and its fields are encoded for the client. + * the child "arrayChild" field (Position) shares visibility with its parent, + * and its fields are encoded for the client. */ encodeMultiple(encoder, state, [client]); @@ -1100,34 +1098,6 @@ describe("Type: Schema", () => { assert.strictEqual(client.state.parent.arrayChild[0].z, 3); }); - it("should encode nested Schema with @inheritVisibility that wasn't initially assigned", () => { - const state = new InheritanceRoot(); - const encoder = getEncoder(state); - - const client = createClientWithView(state); - client.view.add(state.parent); - - // Initial encode: child property is undefined - encodeMultiple(encoder, state, [client]); - - // Assign a child Schema that uses @inheritVisibility - state.parent.inheritingChild = new InheritedPosition(1, 2, 3); - - /** - * Encode an assignment of a child field: - * the child "inheritingChild" field (InheritedPosition) is marked with - * @inheritVisibility, so it shares visibility with its parent and - * its fields are encoded for the client. - */ - encodeMultiple(encoder, state, [client]); - - assert.notStrictEqual(client.state.parent, undefined); - assert.notStrictEqual(client.state.parent.inheritingChild, undefined); - assert.strictEqual(client.state.parent.inheritingChild.x, 1); - assert.strictEqual(client.state.parent.inheritingChild.y, 2); - assert.strictEqual(client.state.parent.inheritingChild.z, 3); - }); - describe("no changes", () => { it("empty state", () => { const state = new State(); diff --git a/test/Schema.ts b/test/Schema.ts index c387321c..e65a9c3e 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -1,6 +1,6 @@ import * as assert from "assert"; -import { Schema, type, ArraySchema, MapSchema, Reflection, Iterator, StateView, inheritVisibility, view } from "../src"; +import { Schema, type, ArraySchema, MapSchema, Reflection, Iterator, StateView, view } from "../src"; import { Decoder } from "../src/decoder/Decoder"; import { Encoder } from "../src/encoder/Encoder"; import { CallbackProxy, getDecoderStateCallbacks, SchemaCallbackProxy } from "../src/decoder/strategy/getDecoderStateCallbacks"; @@ -249,25 +249,10 @@ export class Position extends Schema { } } -@inheritVisibility -export class InheritedPosition extends Schema { - @type("float32") x: number; - @type("float32") y: number; - @type("float32") z: number; - - constructor(x: number, y: number, z: number) { - super(); - this.x = x; - this.y = y; - this.z = z; - } -} - export class InheritanceParent extends Schema { @type(Position) standardChild: Position | undefined = undefined; @view() @type(Position) viewChild: Position | undefined = undefined; @type([Position]) arrayChild: ArraySchema = new ArraySchema(); - @type(InheritedPosition) inheritingChild: InheritedPosition | undefined = undefined; } export class InheritanceRoot extends Schema { From b413e89a98014841d1d3716ce065cf414d640e32 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Wed, 25 Feb 2026 10:57:22 +0000 Subject: [PATCH 11/11] Move tests from Schema.test.ts to StateView.test.ts --- test/Schema.test.ts | 83 +----------------------------------------- test/StateView.test.ts | 83 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 83 deletions(-) diff --git a/test/Schema.test.ts b/test/Schema.test.ts index ccc5277e..382107d1 100644 --- a/test/Schema.test.ts +++ b/test/Schema.test.ts @@ -7,7 +7,7 @@ // import "core-js"; import * as assert from "assert"; -import { State, Player, DeepState, DeepMap, DeepChild, Position, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder, InheritanceRoot, createClientWithView, encodeMultiple } from "./Schema"; +import { State, Player, DeepState, DeepMap, DeepChild, Position, DeepEntity, assertDeepStrictEqualEncodeAll, createInstanceFromReflection, getEncoder } from "./Schema"; import { Schema, ArraySchema, MapSchema, type, Metadata, $changes, Encoder, Decoder, SetSchema, schema, ToJSON, $refId } from "../src"; import { getNormalizedType } from "../src/Metadata"; @@ -1017,87 +1017,6 @@ describe("Type: Schema", () => { assert.deepStrictEqual(decodedState.mapOfNumbers.toJSON(), { 'zero': 0, 'one': 1, 'two': 2, 'three': 3 }); }); - it("should encode nested Schema", () => { - const state = new InheritanceRoot(); - const encoder = getEncoder(state); - - const client = createClientWithView(state); - client.view.add(state.parent); - - // Initial encode: child property is undefined - encodeMultiple(encoder, state, [client]); - - // Assign a child Schema instance. - state.parent.standardChild = new Position(1, 2, 3); - - /** - * Encode an assignment of a child field: - * Its fields should be visible to the client, because it inherits visibility from its parent. - */ - encodeMultiple(encoder, state, [client]); - - assert.notStrictEqual(client.state.parent, undefined); - assert.notStrictEqual(client.state.parent.standardChild, undefined); - assert.strictEqual(client.state.parent.standardChild.x, 1); - assert.strictEqual(client.state.parent.standardChild.y, 2); - assert.strictEqual(client.state.parent.standardChild.z, 3); - }); - - it("should not encode nested Schema with @view", () => { - const state = new InheritanceRoot(); - const encoder = getEncoder(state); - - const client = createClientWithView(state); - client.view.add(state.parent); - - // Initial encode: child property is undefined - encodeMultiple(encoder, state, [client]); - - // Assign a child Schema that uses @view - state.parent.viewChild = new Position(1, 2, 3); - - /** - * Encode an assignment of a child field: - * the child "viewChild" field (Position) is marked with @view, - * so it does not share visibility with its parent, and - * its fields are not encoded for the client. - */ - encodeMultiple(encoder, state, [client]); - - assert.notStrictEqual(client.state.parent, undefined); - assert.notStrictEqual(client.state.parent.viewChild, undefined); - assert.strictEqual(client.state.parent.viewChild.x, undefined); - assert.strictEqual(client.state.parent.viewChild.y, undefined); - assert.strictEqual(client.state.parent.viewChild.z, undefined); - }); - - it("should encode nested Schema wrapped in ArraySchema", () => { - const state = new InheritanceRoot(); - const encoder = getEncoder(state); - - const client = createClientWithView(state); - client.view.add(state.parent); - - // Initial encode: child property is undefined - encodeMultiple(encoder, state, [client]); - - // Assign a child Schema wrapped in an ArraySchema, to demonstrate this workaround. - state.parent.arrayChild.push(new Position(1, 2, 3)); - - /** - * Encode an assignment of a child field wrapped in an ArraySchema - * the child "arrayChild" field (Position) shares visibility with its parent, - * and its fields are encoded for the client. - */ - encodeMultiple(encoder, state, [client]); - - assert.notStrictEqual(client.state.parent, undefined); - assert.strictEqual(client.state.parent.arrayChild.length, 1); - assert.strictEqual(client.state.parent.arrayChild[0].x, 1); - assert.strictEqual(client.state.parent.arrayChild[0].y, 2); - assert.strictEqual(client.state.parent.arrayChild[0].z, 3); - }); - describe("no changes", () => { it("empty state", () => { const state = new State(); diff --git a/test/StateView.test.ts b/test/StateView.test.ts index b6ba79f5..c123e05d 100644 --- a/test/StateView.test.ts +++ b/test/StateView.test.ts @@ -1,7 +1,7 @@ import * as assert from "assert"; import * as util from "util"; import { Schema, type, view, ArraySchema, MapSchema, StateView, Encoder, ChangeTree, $changes, OPERATION, SetSchema, CollectionSchema } from "../src"; -import { createClientWithView, encodeMultiple, assertEncodeAllMultiple, getDecoder, getEncoder, createInstanceFromReflection, encodeAllForView, encodeAllMultiple, assertRefIdCounts } from "./Schema"; +import { createClientWithView, encodeMultiple, assertEncodeAllMultiple, getDecoder, getEncoder, createInstanceFromReflection, encodeAllForView, encodeAllMultiple, assertRefIdCounts, InheritanceRoot, Position } from "./Schema"; import { nanoid } from "nanoid"; describe("StateView", () => { @@ -2595,4 +2595,85 @@ describe("StateView", () => { }); }); + it("should encode nested Schema", () => { + const state = new InheritanceRoot(); + const encoder = getEncoder(state); + + const client = createClientWithView(state); + client.view.add(state.parent); + + // Initial encode: child property is undefined + encodeMultiple(encoder, state, [client]); + + // Assign a child Schema instance. + state.parent.standardChild = new Position(1, 2, 3); + + /** + * Encode an assignment of a child field: + * Its fields should be visible to the client, because it inherits visibility from its parent. + */ + encodeMultiple(encoder, state, [client]); + + assert.notStrictEqual(client.state.parent, undefined); + assert.notStrictEqual(client.state.parent.standardChild, undefined); + assert.strictEqual(client.state.parent.standardChild.x, 1); + assert.strictEqual(client.state.parent.standardChild.y, 2); + assert.strictEqual(client.state.parent.standardChild.z, 3); + }); + + it("should not encode nested Schema with @view", () => { + const state = new InheritanceRoot(); + const encoder = getEncoder(state); + + const client = createClientWithView(state); + client.view.add(state.parent); + + // Initial encode: child property is undefined + encodeMultiple(encoder, state, [client]); + + // Assign a child Schema that uses @view + state.parent.viewChild = new Position(1, 2, 3); + + /** + * Encode an assignment of a child field: + * the child "viewChild" field (Position) is marked with @view, + * so it does not share visibility with its parent, and + * its fields are not encoded for the client. + */ + encodeMultiple(encoder, state, [client]); + + assert.notStrictEqual(client.state.parent, undefined); + assert.notStrictEqual(client.state.parent.viewChild, undefined); + assert.strictEqual(client.state.parent.viewChild.x, undefined); + assert.strictEqual(client.state.parent.viewChild.y, undefined); + assert.strictEqual(client.state.parent.viewChild.z, undefined); + }); + + it("should encode nested Schema wrapped in ArraySchema", () => { + const state = new InheritanceRoot(); + const encoder = getEncoder(state); + + const client = createClientWithView(state); + client.view.add(state.parent); + + // Initial encode: child property is undefined + encodeMultiple(encoder, state, [client]); + + // Assign a child Schema wrapped in an ArraySchema, to demonstrate this workaround. + state.parent.arrayChild.push(new Position(1, 2, 3)); + + /** + * Encode an assignment of a child field wrapped in an ArraySchema + * the child "arrayChild" field (Position) shares visibility with its parent, + * and its fields are encoded for the client. + */ + encodeMultiple(encoder, state, [client]); + + assert.notStrictEqual(client.state.parent, undefined); + assert.strictEqual(client.state.parent.arrayChild.length, 1); + assert.strictEqual(client.state.parent.arrayChild[0].x, 1); + assert.strictEqual(client.state.parent.arrayChild[0].y, 2); + assert.strictEqual(client.state.parent.arrayChild[0].z, 3); + }); + });