Skip to content

Commit 8709ecb

Browse files
committed
Track items' parents, and only iterate dirty subtrees when creating a snapshot
1 parent a634c24 commit 8709ecb

File tree

3 files changed

+117
-22
lines changed

3 files changed

+117
-22
lines changed

src/schema/createSnapshot.ts

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,29 +44,53 @@ export type Snapshot<T> = DeepReadonly<
4444
export interface SnapshotContext {
4545
/** Map of refId → Schema object from decoder.root.refs */
4646
refs: Map<number, any> | undefined;
47+
/** Reverse lookup: Schema object → refId (built lazily) */
48+
objectToRefId: Map<object, number> | undefined;
4749
/** Snapshot results from the previous render pass */
4850
previousResultsByRefId: Map<number, any>;
4951
/** Snapshot results from the current render pass (for cycle detection) */
5052
currentResultsByRefId: Map<number, any>;
53+
/** Set of refIds that have been modified since the last snapshot */
54+
dirtyRefIds: Set<number>;
55+
/** Map of childRefId → parentRefId for ancestor tracking */
56+
parentRefIdMap: Map<number, number>;
57+
/** Current parent refId during traversal (used to build parentRefIdMap) */
58+
currentParentRefId: number;
5159
}
5260

5361
/**
54-
* Finds the refId for a Schema object by searching the decoder's refs map.
62+
* Builds a reverse lookup map from objects to their refIds.
63+
*/
64+
function buildObjectToRefIdMap(refs: Map<number, any>): Map<object, number> {
65+
const map = new Map<object, number>();
66+
for (const [refId, obj] of refs.entries()) {
67+
if (obj !== null && typeof obj === "object") {
68+
map.set(obj, refId);
69+
}
70+
}
71+
return map;
72+
}
73+
74+
/**
75+
* Finds the refId for a Schema object using the reverse lookup map.
5576
*
5677
* In Colyseus 3.x, each Schema instance is assigned a unique numeric refId
5778
* that remains stable across encode/decode cycles. This allows us to track
5879
* object identity even when the JavaScript object references change.
5980
*
6081
* @param node - The Schema object to find the refId for
61-
* @param refs - The decoder.root.refs map
82+
* @param ctx - The snapshot context with the reverse lookup map
6283
* @returns The refId if found, or -1 if not found
6384
*/
64-
function findRefId(node: object, refs: Map<number, any> | undefined): number {
65-
if (!refs) return -1;
66-
for (const [refId, obj] of refs.entries()) {
67-
if (obj === node) return refId;
85+
function findRefId(node: object, ctx: SnapshotContext): number {
86+
if (!ctx.refs) return -1;
87+
88+
// Build the reverse lookup map lazily on first use.
89+
if (!ctx.objectToRefId) {
90+
ctx.objectToRefId = buildObjectToRefIdMap(ctx.refs);
6891
}
69-
return -1;
92+
93+
return ctx.objectToRefId.get(node) ?? -1;
7094
}
7195

7296
/**
@@ -109,13 +133,14 @@ function createSnapshotForArraySchema(
109133
previousResult: any[] | undefined,
110134
ctx: SnapshotContext
111135
): any[] {
112-
const items = Array.from(node);
113-
const snapshotted: any[] = [];
114-
let hasChanged = !previousResult || !Array.isArray(previousResult) || items.length !== previousResult.length;
136+
const length = node.length;
137+
let hasChanged = !previousResult || !Array.isArray(previousResult) || length !== previousResult.length;
138+
139+
const snapshotted: any[] = new Array(length);
115140

116-
for (let i = 0; i < items.length; i++) {
117-
const snapshottedValue = createSnapshot(items[i], ctx);
118-
snapshotted.push(snapshottedValue);
141+
for (let i = 0; i < length; i++) {
142+
const snapshottedValue = createSnapshot(node.at(i), ctx);
143+
snapshotted[i] = snapshottedValue;
119144

120145
if (!hasChanged && previousResult && previousResult[i] !== snapshottedValue) {
121146
hasChanged = true;
@@ -187,17 +212,17 @@ function createSnapshotForSchema(
187212
*/
188213
export function createSnapshot<T>(node: T, ctx: SnapshotContext): Snapshot<T> {
189214
// Pass through primitives and null/undefined.
190-
if (
191-
node === null ||
192-
node === undefined ||
193-
typeof node !== "object" ||
194-
typeof node === "function"
195-
) {
215+
if (node === null || node === undefined || typeof node !== "object") {
196216
return node as Snapshot<T>;
197217
}
198218

199219
// Find the stable refId for this object.
200-
const refId = findRefId(node, ctx.refs);
220+
const refId = findRefId(node, ctx);
221+
222+
// Record the parent relationship for ancestor tracking.
223+
if (refId !== -1 && ctx.currentParentRefId !== -1) {
224+
ctx.parentRefIdMap.set(refId, ctx.currentParentRefId);
225+
}
201226

202227
// Check if we've already snapshotted this object in the current pass (cycle detection).
203228
if (refId !== -1 && ctx.currentResultsByRefId.has(refId)) {
@@ -207,6 +232,18 @@ export function createSnapshot<T>(node: T, ctx: SnapshotContext): Snapshot<T> {
207232
// Get the previous result for structural sharing comparison.
208233
const previousResult = refId !== -1 ? ctx.previousResultsByRefId.get(refId) : undefined;
209234

235+
// If this node is not dirty and we have a previous result,
236+
// we can skip the entire subtree. With ancestor tracking, if any descendant
237+
// changed, this node would have been marked dirty too.
238+
if (refId !== -1 && previousResult !== undefined && !ctx.dirtyRefIds.has(refId)) {
239+
ctx.currentResultsByRefId.set(refId, previousResult);
240+
return previousResult as Snapshot<T>;
241+
}
242+
243+
// Set this node as the parent for any children we process.
244+
const savedParentRefId = ctx.currentParentRefId;
245+
ctx.currentParentRefId = refId;
246+
210247
let result: any;
211248

212249
if (node instanceof MapSchema) {
@@ -220,7 +257,8 @@ export function createSnapshot<T>(node: T, ctx: SnapshotContext): Snapshot<T> {
220257
result = node;
221258
}
222259

223-
// Cache the result for this snapshot pass.
260+
// Restore parent and cache result.
261+
ctx.currentParentRefId = savedParentRefId;
224262
if (refId !== -1) {
225263
ctx.currentResultsByRefId.set(refId, result);
226264
}

src/schema/getOrCreateSubscription.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@ import { Schema, Decoder, DataChange } from "@colyseus/schema";
55
* Subscription state for a single room, shared across all hook instances
66
* consuming the same room state.
77
*/
8-
interface StateSubscription {
8+
export interface StateSubscription {
99
/** Set of callbacks to invoke when state changes */
1010
listeners: Set<() => void>;
1111
/** Cached snapshot results from the previous render, keyed by refId */
1212
previousResultsByRefId: Map<number, any>;
13+
/** Set of refIds that have been modified since the last snapshot */
14+
dirtyRefIds: Set<number>;
15+
/** Map of childRefId → parentRefId for ancestor tracking */
16+
parentRefIdMap: Map<number, number>;
17+
/** Reverse lookup: Schema object → refId (rebuilt on each change) */
18+
objectToRefId: Map<object, number> | undefined;
19+
/** Counter for periodic pruning of stale cache entries */
20+
cleanupCounter: number;
1321
/** Original triggerChanges function from the decoder */
1422
originalTrigger?: (changes: DataChange[]) => void;
1523
}
@@ -37,6 +45,10 @@ export function getOrCreateSubscription(roomState: Schema, decoder: Decoder): St
3745
subscription = {
3846
listeners: new Set(),
3947
previousResultsByRefId: new Map(),
48+
dirtyRefIds: new Set(),
49+
parentRefIdMap: new Map(),
50+
objectToRefId: undefined,
51+
cleanupCounter: 0,
4052
};
4153

4254
// Wrap the decoder's triggerChanges to notify React subscribers.
@@ -48,6 +60,30 @@ export function getOrCreateSubscription(roomState: Schema, decoder: Decoder): St
4860
subscription.originalTrigger(changes);
4961
}
5062

63+
// Rebuild reverse lookup since refs may have changed.
64+
const refs = decoder.root?.refs;
65+
if (refs) {
66+
subscription.objectToRefId = new Map();
67+
for (const [refId, obj] of refs.entries()) {
68+
if (obj !== null && typeof obj === "object") {
69+
subscription.objectToRefId.set(obj, refId);
70+
}
71+
}
72+
}
73+
74+
// Mark all changed refIds as dirty, walking up the parent chain.
75+
for (const change of changes) {
76+
const refId = subscription.objectToRefId?.get(change.ref) ?? -1;
77+
if (refId !== -1) {
78+
// Mark this ref and all its ancestors as dirty.
79+
let currentRefId: number | undefined = refId;
80+
while (currentRefId !== undefined) {
81+
subscription.dirtyRefIds.add(currentRefId);
82+
currentRefId = subscription.parentRefIdMap.get(currentRefId);
83+
}
84+
}
85+
}
86+
5187
// Notify all React subscribers that state has changed.
5288
subscription.listeners.forEach((callback) => callback());
5389
};

src/schema/useColyseusState.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ export function useColyseusState<T extends Schema = Schema, U = T>(
5353
// Create context for this snapshot pass.
5454
const ctx: SnapshotContext = {
5555
refs: decoder.root?.refs,
56+
objectToRefId: subscription.objectToRefId,
5657
previousResultsByRefId: subscription.previousResultsByRefId,
5758
currentResultsByRefId: new Map(),
59+
dirtyRefIds: subscription.dirtyRefIds,
60+
parentRefIdMap: subscription.parentRefIdMap,
61+
currentParentRefId: -1, // No parent for root
5862
};
5963

6064
const result = createSnapshot(selectedState, ctx);
@@ -64,6 +68,23 @@ export function useColyseusState<T extends Schema = Schema, U = T>(
6468
subscription.previousResultsByRefId.set(refId, value);
6569
}
6670

71+
// Save the objectToRefId map for reuse.
72+
subscription.objectToRefId = ctx.objectToRefId;
73+
74+
// Clear dirty refs after snapshot is complete.
75+
subscription.dirtyRefIds.clear();
76+
77+
// Periodically prune stale cache entries (every 100 snapshots).
78+
if (++subscription.cleanupCounter >= 100 && ctx.refs) {
79+
subscription.cleanupCounter = 0;
80+
for (const refId of subscription.previousResultsByRefId.keys()) {
81+
if (!ctx.refs.has(refId)) {
82+
subscription.previousResultsByRefId.delete(refId);
83+
subscription.parentRefIdMap.delete(refId);
84+
}
85+
}
86+
}
87+
6788
return result;
6889
};
6990

0 commit comments

Comments
 (0)