Skip to content

Commit 0b5efbb

Browse files
committed
add 'onChange' callback back to collections. #155
1 parent 136bf11 commit 0b5efbb

File tree

4 files changed

+93
-17
lines changed

4 files changed

+93
-17
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@colyseus/schema",
3-
"version": "3.0.2",
3+
"version": "3.0.3",
44
"description": "Binary state serializer with delta encoding for games",
55
"bin": {
66
"schema-codegen": "./bin/schema-codegen",

src/decoder/DecodeOperation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function decodeValue(
7575
// });
7676
}
7777

78-
value = null;
78+
value = undefined;
7979
}
8080

8181
if (operation === OPERATION.DELETE) {

src/decoder/strategy/StateCallbacks.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,15 @@ type CollectionCallback<K, V> = {
7979
*/
8080
onRemove(callback: (item: V, index: K) => void): () => void;
8181

82-
// /**
83-
// * Trigger callback when an item has been removed to the collection.
84-
// *
85-
// * @param callback
86-
// */
87-
// onChange(callback: (item: V, index: K) => void): void;
82+
/**
83+
* Trigger callback when the value on a key has changed.
84+
*
85+
* THIS METHOD IS NOT RECURSIVE!
86+
* If you want to listen to changes on individual items, you need to attach callbacks to the them directly inside the `onAdd` callback.
87+
*
88+
* @param callback
89+
*/
90+
onChange(callback: (item: V, index: K) => void): void;
8891
};
8992

9093
type OnInstanceAvailableCallback = (callback: (ref: Ref, existing: boolean) => void) => void;
@@ -190,7 +193,12 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
190193
}
191194

192195
// trigger onChange
193-
if (change.value !== change.previousValue) {
196+
if (
197+
change.value !== change.previousValue &&
198+
// FIXME: see "should not encode item if added and removed at the same patch" test case.
199+
// some "ADD" + "DELETE" operations on same patch are being encoded as "DELETE"
200+
(change.value !== undefined || change.previousValue !== undefined)
201+
) {
194202
const replaceCallbacks = $callbacks[OPERATION.REPLACE];
195203
for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
196204
replaceCallbacks[i](change.value, change.dynamicIndex ?? change.field);
@@ -339,6 +347,10 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
339347
return $root.addCallback($root.refIds.get(ref), OPERATION.DELETE, callback);
340348
};
341349

350+
const onChange = function (ref: Ref, callback: (value: any, key: any) => void) {
351+
return $root.addCallback($root.refIds.get(ref), OPERATION.REPLACE, callback);
352+
};
353+
342354
return new Proxy({
343355
onAdd: function(callback: (value, key) => void, immediate: boolean = true) {
344356
//
@@ -375,6 +387,21 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
375387
return onRemove(context.instance, callback);
376388
}
377389
},
390+
onChange: function(callback: (value, key) => void) {
391+
if (context.onInstanceAvailable) {
392+
// collection instance not received yet
393+
let detachCallback = () => {};
394+
395+
context.onInstanceAvailable((ref: Ref) => {
396+
detachCallback = onChange(ref, callback)
397+
});
398+
399+
return () => detachCallback();
400+
401+
} else if (context.instance) {
402+
return onChange(context.instance, callback);
403+
}
404+
},
378405
}, {
379406
get(target, prop: string) {
380407
if (!target[prop]) {
@@ -390,7 +417,7 @@ export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>):
390417
}
391418

392419
function $<T>(instance: T): CallbackProxy<T> {
393-
return getProxy(undefined, { instance }) as CallbackProxy<T>;
420+
return getProxy(undefined, { instance }) as unknown as CallbackProxy<T>;
394421
}
395422

396423
return $;

test/MapSchema.test.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ describe("Type: MapSchema", () => {
372372
assertDeepStrictEqualEncodeAll(state);
373373
});
374374

375-
it("should not encode item if added and removed at the same patch", () => {
375+
it("should not encode item if added and removed at the same patch (Schema child)", () => {
376376
const state = new State();
377377
state.mapOfPlayers = new MapSchema<Player>();
378378
state.mapOfPlayers.set('one', new Player("Jake", 10, 10));
@@ -383,9 +383,10 @@ describe("Type: MapSchema", () => {
383383

384384
let onRemoveCalls = 0;
385385
let onAddCalls = 0;
386-
387-
$(decodedState).mapOfPlayers.onRemove(() => onRemoveCalls++);
388-
$(decodedState).mapOfPlayers.onAdd(() => onAddCalls++);
386+
let onChangeCalls = 0;
387+
$(decodedState).mapOfPlayers.onRemove((value, key) => onRemoveCalls++);
388+
$(decodedState).mapOfPlayers.onAdd((_, key) => onAddCalls++);
389+
$(decodedState).mapOfPlayers.onChange((_, key) => onChangeCalls++);
389390

390391
decodedState.decode(state.encode());
391392

@@ -396,9 +397,9 @@ describe("Type: MapSchema", () => {
396397
const patchBytes = state.encode();
397398

398399
//
399-
// TODO: improve me! `DELETE` operation should not be encoded here.
400-
// this test conflicts with encodeAll() + encode() for other structures, where DELETE operation is necessary.
401-
// // assert.deepStrictEqual([ 4, 1, 0, 1, 11, 193 ], patchBytes);
400+
// TODO / FIXME: There's an additional 2 bytes for the "remove" operation here, even though no "add" was made.
401+
// (the "ADD" + "DELETE" operations on same patch are being encoded as "DELETE")
402+
// // assert.deepStrictEqual([ 255, 2, 129, 11, 255, 1 ], Array.from(patchBytes));
402403
//
403404

404405
decodedState.decode(patchBytes);
@@ -408,8 +409,56 @@ describe("Type: MapSchema", () => {
408409
state.mapOfPlayers.delete('one');
409410

410411
decodedState.decode(state.encode());
412+
assert.deepStrictEqual(state.toJSON(), decodedState.toJSON());
413+
414+
assert.strictEqual(1, onRemoveCalls);
415+
assert.strictEqual(1, onAddCalls);
416+
assert.strictEqual(2, onChangeCalls);
417+
418+
assertDeepStrictEqualEncodeAll(state);
419+
});
420+
421+
it("should not encode item if added and removed at the same patch (primitive child)", () => {
422+
class MyState extends Schema {
423+
@type({ map: "string" }) mapOfStrings = new MapSchema<string>();
424+
}
425+
const state = new MyState();
426+
state.mapOfStrings.set('one', "one");
427+
428+
const decodedState = new MyState();
429+
const $ = getCallbacks(decodedState);
430+
431+
let onRemoveCalls = 0;
432+
let onAddCalls = 0;
433+
let onChangeCalls = 0;
434+
$(decodedState).mapOfStrings.onRemove(() => onRemoveCalls++);
435+
$(decodedState).mapOfStrings.onAdd(() => onAddCalls++);
436+
$(decodedState).mapOfStrings.onChange(() => onChangeCalls++);
437+
438+
decodedState.decode(state.encode());
439+
440+
state.mapOfStrings.set('two', "two");
441+
state.mapOfStrings.delete('two');
442+
443+
const patchBytes = state.encode();
444+
445+
//
446+
// TODO / FIXME: There's an additional 2 bytes for the "remove" operation here, even though no "add" was made.
447+
// (the "ADD" + "DELETE" operations on same patch are being encoded as "DELETE")
448+
// // assert.deepStrictEqual([ 255, 2, 129, 11, 255, 1 ], Array.from(patchBytes));
449+
//
450+
451+
decodedState.decode(patchBytes);
452+
assert.strictEqual(0, onRemoveCalls);
453+
454+
state.mapOfStrings.delete('one');
455+
456+
decodedState.decode(state.encode());
457+
assert.deepStrictEqual(state.toJSON(), decodedState.toJSON());
411458

412459
assert.strictEqual(1, onRemoveCalls);
460+
assert.strictEqual(1, onAddCalls);
461+
assert.strictEqual(2, onChangeCalls);
413462

414463
assertDeepStrictEqualEncodeAll(state);
415464
});

0 commit comments

Comments
 (0)