@colyseus/react 0.1.0
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.
- package/README.md +29 -0
- package/dist/index.cjs +271 -0
- package/dist/index.d.cts +56 -0
- package/dist/index.d.mts +56 -0
- package/dist/index.mjs +271 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Experimenting with `useRoomState` hook
|
|
2
|
+
|
|
3
|
+
> This is a work-in-progress experiment and currently does not work as expected.
|
|
4
|
+
|
|
5
|
+
The idea here is to create a React hook that is able to trigger a re-render only when the requested portion of the state receives an update.
|
|
6
|
+
|
|
7
|
+
Since `@colyseus/schema` v3 exposes the `.triggerChanges` method from the `Decoder` - we can hopefully use this to our advantage.
|
|
8
|
+
|
|
9
|
+
Inspiration/previous work has been done by [@pedr0fontoura](https://github.com/pedr0fontoura) on [pedr0fontoura/use-colyseus/](https://github.com/pedr0fontoura/use-colyseus/)
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
The `App.tsx` uses `useRoomState()` to "subscribe" to simulated room state changes:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
function App() {
|
|
17
|
+
const state = useRoomState((state) => state);
|
|
18
|
+
// ...
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The simulation of server-side changes are made via `simulatePatchState()`:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
simulatePatchState((state) => {
|
|
26
|
+
const randomKey = Array.from(state.players.keys())[Math.floor(Math.random() * state.players.size)];
|
|
27
|
+
state.players.delete(randomKey);
|
|
28
|
+
});
|
|
29
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
let react = require("react");
|
|
2
|
+
let _colyseus_schema = require("@colyseus/schema");
|
|
3
|
+
|
|
4
|
+
//#region src/schema/createSnapshot.ts
|
|
5
|
+
/**
|
|
6
|
+
* Builds a reverse lookup map from objects to their refIds.
|
|
7
|
+
*/
|
|
8
|
+
function buildObjectToRefIdMap(refs) {
|
|
9
|
+
const map = /* @__PURE__ */ new Map();
|
|
10
|
+
for (const [refId, obj] of refs.entries()) if (obj !== null && typeof obj === "object") map.set(obj, refId);
|
|
11
|
+
return map;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Finds the refId for a Schema object using the reverse lookup map.
|
|
15
|
+
*
|
|
16
|
+
* In Colyseus 3.x, each Schema instance is assigned a unique numeric refId
|
|
17
|
+
* that remains stable across encode/decode cycles. This allows us to track
|
|
18
|
+
* object identity even when the JavaScript object references change.
|
|
19
|
+
*
|
|
20
|
+
* @param node - The Schema object to find the refId for
|
|
21
|
+
* @param ctx - The snapshot context with the reverse lookup map
|
|
22
|
+
* @returns The refId if found, or -1 if not found
|
|
23
|
+
*/
|
|
24
|
+
function findRefId(node, ctx) {
|
|
25
|
+
if (!ctx.refs) return -1;
|
|
26
|
+
if (!ctx.objectToRefId) ctx.objectToRefId = buildObjectToRefIdMap(ctx.refs);
|
|
27
|
+
return ctx.objectToRefId.get(node) ?? -1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates a snapshot of a MapSchema into a plain JavaScript object with structural sharing.
|
|
31
|
+
*/
|
|
32
|
+
function createSnapshotForMapSchema(node, previousResult, ctx) {
|
|
33
|
+
const snapshotted = {};
|
|
34
|
+
let hasChanged = previousResult === void 0;
|
|
35
|
+
for (const [key, value] of node) {
|
|
36
|
+
const snapshottedValue = createSnapshot(value, ctx);
|
|
37
|
+
snapshotted[key] = snapshottedValue;
|
|
38
|
+
if (!hasChanged && previousResult) {
|
|
39
|
+
if (!(key in previousResult) || previousResult[key] !== snapshottedValue) hasChanged = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!hasChanged && previousResult) {
|
|
43
|
+
if (Object.keys(previousResult).length !== Object.keys(snapshotted).length) hasChanged = true;
|
|
44
|
+
}
|
|
45
|
+
return hasChanged ? snapshotted : previousResult;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Creates a snapshot of an ArraySchema into a plain JavaScript array with structural sharing.
|
|
49
|
+
*/
|
|
50
|
+
function createSnapshotForArraySchema(node, previousResult, ctx) {
|
|
51
|
+
const length = node.length;
|
|
52
|
+
let hasChanged = !previousResult || !Array.isArray(previousResult) || length !== previousResult.length;
|
|
53
|
+
const snapshotted = new Array(length);
|
|
54
|
+
for (let i = 0; i < length; i++) {
|
|
55
|
+
const snapshottedValue = createSnapshot(node.at(i), ctx);
|
|
56
|
+
snapshotted[i] = snapshottedValue;
|
|
57
|
+
if (!hasChanged && previousResult && previousResult[i] !== snapshottedValue) hasChanged = true;
|
|
58
|
+
}
|
|
59
|
+
return hasChanged ? snapshotted : previousResult;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates a snapshot of a Schema object into a plain JavaScript object with structural sharing.
|
|
63
|
+
*/
|
|
64
|
+
function createSnapshotForSchema(node, previousResult, ctx) {
|
|
65
|
+
const snapshotted = {};
|
|
66
|
+
let hasChanged = previousResult === void 0;
|
|
67
|
+
const fieldDefinitions = node._definition?.fields;
|
|
68
|
+
if (fieldDefinitions) for (const fieldName in fieldDefinitions) {
|
|
69
|
+
const value = node[fieldName];
|
|
70
|
+
if (typeof value !== "function") {
|
|
71
|
+
const snapshottedValue = createSnapshot(value, ctx);
|
|
72
|
+
snapshotted[fieldName] = snapshottedValue;
|
|
73
|
+
if (!hasChanged && previousResult && previousResult[fieldName] !== snapshottedValue) hasChanged = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else for (const key in node) {
|
|
77
|
+
if (key.startsWith("_") || key.startsWith("$")) continue;
|
|
78
|
+
const value = node[key];
|
|
79
|
+
if (typeof value !== "function") {
|
|
80
|
+
const snapshottedValue = createSnapshot(value, ctx);
|
|
81
|
+
snapshotted[key] = snapshottedValue;
|
|
82
|
+
if (!hasChanged && previousResult && previousResult[key] !== snapshottedValue) hasChanged = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return hasChanged ? snapshotted : previousResult;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Recursively creates a snapshot of a Colyseus Schema node into plain JavaScript objects.
|
|
89
|
+
*
|
|
90
|
+
* This function implements structural sharing: if a node and all its descendants
|
|
91
|
+
* are unchanged from the previous render, the previous snapshot result is reused.
|
|
92
|
+
* This ensures referential equality for unchanged subtrees, allowing React memoization.
|
|
93
|
+
*
|
|
94
|
+
* @param node - The value to snapshot (may be a Schema, primitive, etc.)
|
|
95
|
+
* @param ctx - The snapshot context with refs and previous results
|
|
96
|
+
* @returns The snapshotted plain JavaScript value
|
|
97
|
+
*/
|
|
98
|
+
function createSnapshot(node, ctx) {
|
|
99
|
+
if (node === null || node === void 0 || typeof node !== "object") return node;
|
|
100
|
+
const refId = findRefId(node, ctx);
|
|
101
|
+
if (refId !== -1 && ctx.currentParentRefId !== -1) ctx.parentRefIdMap.set(refId, ctx.currentParentRefId);
|
|
102
|
+
if (refId !== -1 && ctx.currentResultsByRefId.has(refId)) return ctx.currentResultsByRefId.get(refId);
|
|
103
|
+
const previousResult = refId !== -1 ? ctx.previousResultsByRefId.get(refId) : void 0;
|
|
104
|
+
if (refId !== -1 && previousResult !== void 0 && !ctx.dirtyRefIds.has(refId)) {
|
|
105
|
+
ctx.currentResultsByRefId.set(refId, previousResult);
|
|
106
|
+
return previousResult;
|
|
107
|
+
}
|
|
108
|
+
const savedParentRefId = ctx.currentParentRefId;
|
|
109
|
+
ctx.currentParentRefId = refId;
|
|
110
|
+
let result;
|
|
111
|
+
if (node instanceof _colyseus_schema.MapSchema) result = createSnapshotForMapSchema(node, previousResult, ctx);
|
|
112
|
+
else if (node instanceof _colyseus_schema.ArraySchema) result = createSnapshotForArraySchema(node, previousResult, ctx);
|
|
113
|
+
else if (node instanceof _colyseus_schema.Schema) result = createSnapshotForSchema(node, previousResult, ctx);
|
|
114
|
+
else result = node;
|
|
115
|
+
ctx.currentParentRefId = savedParentRefId;
|
|
116
|
+
if (refId !== -1) ctx.currentResultsByRefId.set(refId, result);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/schema/getOrCreateSubscription.ts
|
|
122
|
+
/** WeakMap to store subscriptions per room state instance */
|
|
123
|
+
const subscriptionsByState = /* @__PURE__ */ new WeakMap();
|
|
124
|
+
/**
|
|
125
|
+
* Gets or creates a subscription for the given room state.
|
|
126
|
+
*
|
|
127
|
+
* This function sets up change notification by wrapping the decoder's
|
|
128
|
+
* `triggerChanges` method to notify all subscribed React components.
|
|
129
|
+
*
|
|
130
|
+
* @param roomState - The Colyseus room state Schema instance
|
|
131
|
+
* @param decoder - The Colyseus decoder associated with the room
|
|
132
|
+
* @returns The subscription object for this room state
|
|
133
|
+
*/
|
|
134
|
+
function getOrCreateSubscription(roomState, decoder) {
|
|
135
|
+
let subscription = subscriptionsByState.get(roomState);
|
|
136
|
+
if (subscription) return subscription;
|
|
137
|
+
subscription = {
|
|
138
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
139
|
+
previousResultsByRefId: /* @__PURE__ */ new Map(),
|
|
140
|
+
dirtyRefIds: /* @__PURE__ */ new Set(),
|
|
141
|
+
parentRefIdMap: /* @__PURE__ */ new Map(),
|
|
142
|
+
objectToRefId: void 0,
|
|
143
|
+
cleanupCounter: 0
|
|
144
|
+
};
|
|
145
|
+
subscription.originalTrigger = decoder.triggerChanges?.bind(decoder);
|
|
146
|
+
decoder.triggerChanges = (changes) => {
|
|
147
|
+
if (subscription.originalTrigger) subscription.originalTrigger(changes);
|
|
148
|
+
const refs = decoder.root?.refs;
|
|
149
|
+
if (refs) {
|
|
150
|
+
subscription.objectToRefId = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const [refId, obj] of refs.entries()) if (obj !== null && typeof obj === "object") subscription.objectToRefId.set(obj, refId);
|
|
152
|
+
}
|
|
153
|
+
for (const change of changes) {
|
|
154
|
+
const refId = subscription.objectToRefId?.get(change.ref) ?? -1;
|
|
155
|
+
if (refId !== -1) {
|
|
156
|
+
let currentRefId = refId;
|
|
157
|
+
while (currentRefId !== void 0) {
|
|
158
|
+
subscription.dirtyRefIds.add(currentRefId);
|
|
159
|
+
currentRefId = subscription.parentRefIdMap.get(currentRefId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
subscription.listeners.forEach((callback) => callback());
|
|
164
|
+
};
|
|
165
|
+
subscriptionsByState.set(roomState, subscription);
|
|
166
|
+
return subscription;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/schema/useColyseusState.ts
|
|
171
|
+
/**
|
|
172
|
+
* React hook that provides immutable snapshots of Colyseus room state
|
|
173
|
+
* with structural sharing to minimize re-renders.
|
|
174
|
+
*
|
|
175
|
+
* This hook subscribes to state changes from the Colyseus decoder and
|
|
176
|
+
* produces plain JavaScript snapshots of the state. Unchanged portions
|
|
177
|
+
* of the state tree maintain referential equality between renders, enabling
|
|
178
|
+
* efficient React component updates.
|
|
179
|
+
*
|
|
180
|
+
* @template T - The root Schema type of the room state
|
|
181
|
+
* @template U - The selected portion of state (defaults to full state)
|
|
182
|
+
*
|
|
183
|
+
* @param roomState - The Colyseus room state Schema instance
|
|
184
|
+
* @param decoder - The Colyseus Decoder associated with the room
|
|
185
|
+
* @param selector - Optional function to select a portion of the state
|
|
186
|
+
*
|
|
187
|
+
* @returns The snapshotted, immutable state
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```tsx
|
|
191
|
+
* // Use the full state
|
|
192
|
+
* const state = useColyseusState(room.state, decoder);
|
|
193
|
+
*
|
|
194
|
+
* // Use with a selector to only subscribe to part of the state
|
|
195
|
+
* const players = useColyseusState(room.state, decoder, (s) => s.players);
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
function useColyseusState(roomState, decoder, selector = (s) => s) {
|
|
199
|
+
(0, react.useEffect)(() => {
|
|
200
|
+
if (roomState && decoder) getOrCreateSubscription(roomState, decoder);
|
|
201
|
+
}, [roomState, decoder]);
|
|
202
|
+
const getSnapshot = () => {
|
|
203
|
+
if (!roomState || !decoder) return;
|
|
204
|
+
const subscription = getOrCreateSubscription(roomState, decoder);
|
|
205
|
+
const selectedState = selector(roomState);
|
|
206
|
+
const ctx = {
|
|
207
|
+
refs: decoder.root?.refs,
|
|
208
|
+
objectToRefId: subscription.objectToRefId,
|
|
209
|
+
previousResultsByRefId: subscription.previousResultsByRefId,
|
|
210
|
+
currentResultsByRefId: /* @__PURE__ */ new Map(),
|
|
211
|
+
dirtyRefIds: subscription.dirtyRefIds,
|
|
212
|
+
parentRefIdMap: subscription.parentRefIdMap,
|
|
213
|
+
currentParentRefId: -1
|
|
214
|
+
};
|
|
215
|
+
const result = createSnapshot(selectedState, ctx);
|
|
216
|
+
for (const [refId, value] of ctx.currentResultsByRefId) subscription.previousResultsByRefId.set(refId, value);
|
|
217
|
+
subscription.objectToRefId = ctx.objectToRefId;
|
|
218
|
+
subscription.dirtyRefIds.clear();
|
|
219
|
+
if (++subscription.cleanupCounter >= 100 && ctx.refs) {
|
|
220
|
+
subscription.cleanupCounter = 0;
|
|
221
|
+
for (const refId of subscription.previousResultsByRefId.keys()) if (!ctx.refs.has(refId)) {
|
|
222
|
+
subscription.previousResultsByRefId.delete(refId);
|
|
223
|
+
subscription.parentRefIdMap.delete(refId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
};
|
|
228
|
+
const subscribe = (callback) => {
|
|
229
|
+
if (!roomState || !decoder) return () => {};
|
|
230
|
+
const subscription = getOrCreateSubscription(roomState, decoder);
|
|
231
|
+
subscription.listeners.add(callback);
|
|
232
|
+
return () => subscription.listeners.delete(callback);
|
|
233
|
+
};
|
|
234
|
+
return (0, react.useSyncExternalStore)(subscribe, getSnapshot);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/schema/useRoomState.ts
|
|
239
|
+
/**
|
|
240
|
+
* React hook that provides immutable snapshots of Colyseus room state
|
|
241
|
+
* with structural sharing to minimize re-renders.
|
|
242
|
+
*
|
|
243
|
+
* This hook subscribes to state changes from the room's Colyseus decoder
|
|
244
|
+
* and produces plain JavaScript snapshots of the state. Unchanged portions
|
|
245
|
+
* of the state tree maintain referential equality between renders, enabling
|
|
246
|
+
* efficient React component updates.
|
|
247
|
+
*
|
|
248
|
+
* @template T - The root Schema type of the room state
|
|
249
|
+
* @template U - The selected portion of state (defaults to full state)
|
|
250
|
+
*
|
|
251
|
+
* @param room - The Colyseus room instance whose state should be snapshotted
|
|
252
|
+
* @param selector - Optional function to select a portion of the state
|
|
253
|
+
*
|
|
254
|
+
* @returns The snapshotted, immutable state
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```tsx
|
|
258
|
+
* // Use the full state
|
|
259
|
+
* const state = useRoomState(room);
|
|
260
|
+
*
|
|
261
|
+
* // Use with a selector to only subscribe to part of the state
|
|
262
|
+
* const players = useRoomState(room, (s) => s.players);
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
function useRoomState(room, selector = (s) => s) {
|
|
266
|
+
const decoder = room.serializer.decoder;
|
|
267
|
+
return useColyseusState(room.state, decoder, selector);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
//#endregion
|
|
271
|
+
exports.useRoomState = useRoomState;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ArraySchema, MapSchema, Schema } from "@colyseus/schema";
|
|
2
|
+
import { Room } from "@colyseus/sdk";
|
|
3
|
+
|
|
4
|
+
//#region src/schema/createSnapshot.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Remove function properties from a type.
|
|
7
|
+
*/
|
|
8
|
+
type OmitFunctions<T> = Omit<T, { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]>;
|
|
9
|
+
/**
|
|
10
|
+
* Recursively applies `readonly` to all properties of a type.
|
|
11
|
+
*/
|
|
12
|
+
type DeepReadonly<T> = T extends (infer R)[] ? ReadonlyArray<DeepReadonly<R>> : T extends Record<string, any> ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
|
|
13
|
+
/**
|
|
14
|
+
* Transforms a Colyseus Schema type into an immutable, plain JavaScript type.
|
|
15
|
+
*
|
|
16
|
+
* - `ArraySchema<T>` becomes `readonly T[]`
|
|
17
|
+
* - `MapSchema<T>` becomes `Readonly<Record<string, T>>`
|
|
18
|
+
* - `Schema` subclasses become plain objects with only data properties
|
|
19
|
+
* - Primitives remain unchanged
|
|
20
|
+
*
|
|
21
|
+
* @template T - The Colyseus Schema type to snapshot
|
|
22
|
+
*/
|
|
23
|
+
type Snapshot<T> = DeepReadonly<T extends ArraySchema<infer U> ? Snapshot<U>[] : T extends MapSchema<infer U> ? Record<string, Snapshot<U>> : T extends Schema ? { [K in keyof OmitFunctions<T>]: Snapshot<OmitFunctions<T>[K]> } : T>;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/schema/useRoomState.d.ts
|
|
26
|
+
/**
|
|
27
|
+
* React hook that provides immutable snapshots of Colyseus room state
|
|
28
|
+
* with structural sharing to minimize re-renders.
|
|
29
|
+
*
|
|
30
|
+
* This hook subscribes to state changes from the room's Colyseus decoder
|
|
31
|
+
* and produces plain JavaScript snapshots of the state. Unchanged portions
|
|
32
|
+
* of the state tree maintain referential equality between renders, enabling
|
|
33
|
+
* efficient React component updates.
|
|
34
|
+
*
|
|
35
|
+
* @template T - The root Schema type of the room state
|
|
36
|
+
* @template U - The selected portion of state (defaults to full state)
|
|
37
|
+
*
|
|
38
|
+
* @param room - The Colyseus room instance whose state should be snapshotted
|
|
39
|
+
* @param selector - Optional function to select a portion of the state
|
|
40
|
+
*
|
|
41
|
+
* @returns The snapshotted, immutable state
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* // Use the full state
|
|
46
|
+
* const state = useRoomState(room);
|
|
47
|
+
*
|
|
48
|
+
* // Use with a selector to only subscribe to part of the state
|
|
49
|
+
* const players = useRoomState(room, (s) => s.players);
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
declare function useRoomState<T extends Schema, U = T>(room: Room<{
|
|
53
|
+
state: T;
|
|
54
|
+
}>, selector?: (state: T) => U): Snapshot<U> | undefined;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { type Snapshot, useRoomState };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ArraySchema, MapSchema, Schema } from "@colyseus/schema";
|
|
2
|
+
import { Room } from "@colyseus/sdk";
|
|
3
|
+
|
|
4
|
+
//#region src/schema/createSnapshot.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Remove function properties from a type.
|
|
7
|
+
*/
|
|
8
|
+
type OmitFunctions<T> = Omit<T, { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]>;
|
|
9
|
+
/**
|
|
10
|
+
* Recursively applies `readonly` to all properties of a type.
|
|
11
|
+
*/
|
|
12
|
+
type DeepReadonly<T> = T extends (infer R)[] ? ReadonlyArray<DeepReadonly<R>> : T extends Record<string, any> ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
|
|
13
|
+
/**
|
|
14
|
+
* Transforms a Colyseus Schema type into an immutable, plain JavaScript type.
|
|
15
|
+
*
|
|
16
|
+
* - `ArraySchema<T>` becomes `readonly T[]`
|
|
17
|
+
* - `MapSchema<T>` becomes `Readonly<Record<string, T>>`
|
|
18
|
+
* - `Schema` subclasses become plain objects with only data properties
|
|
19
|
+
* - Primitives remain unchanged
|
|
20
|
+
*
|
|
21
|
+
* @template T - The Colyseus Schema type to snapshot
|
|
22
|
+
*/
|
|
23
|
+
type Snapshot<T> = DeepReadonly<T extends ArraySchema<infer U> ? Snapshot<U>[] : T extends MapSchema<infer U> ? Record<string, Snapshot<U>> : T extends Schema ? { [K in keyof OmitFunctions<T>]: Snapshot<OmitFunctions<T>[K]> } : T>;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/schema/useRoomState.d.ts
|
|
26
|
+
/**
|
|
27
|
+
* React hook that provides immutable snapshots of Colyseus room state
|
|
28
|
+
* with structural sharing to minimize re-renders.
|
|
29
|
+
*
|
|
30
|
+
* This hook subscribes to state changes from the room's Colyseus decoder
|
|
31
|
+
* and produces plain JavaScript snapshots of the state. Unchanged portions
|
|
32
|
+
* of the state tree maintain referential equality between renders, enabling
|
|
33
|
+
* efficient React component updates.
|
|
34
|
+
*
|
|
35
|
+
* @template T - The root Schema type of the room state
|
|
36
|
+
* @template U - The selected portion of state (defaults to full state)
|
|
37
|
+
*
|
|
38
|
+
* @param room - The Colyseus room instance whose state should be snapshotted
|
|
39
|
+
* @param selector - Optional function to select a portion of the state
|
|
40
|
+
*
|
|
41
|
+
* @returns The snapshotted, immutable state
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* // Use the full state
|
|
46
|
+
* const state = useRoomState(room);
|
|
47
|
+
*
|
|
48
|
+
* // Use with a selector to only subscribe to part of the state
|
|
49
|
+
* const players = useRoomState(room, (s) => s.players);
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
declare function useRoomState<T extends Schema, U = T>(room: Room<{
|
|
53
|
+
state: T;
|
|
54
|
+
}>, selector?: (state: T) => U): Snapshot<U> | undefined;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { type Snapshot, useRoomState };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import { ArraySchema, MapSchema, Schema } from "@colyseus/schema";
|
|
3
|
+
|
|
4
|
+
//#region src/schema/createSnapshot.ts
|
|
5
|
+
/**
|
|
6
|
+
* Builds a reverse lookup map from objects to their refIds.
|
|
7
|
+
*/
|
|
8
|
+
function buildObjectToRefIdMap(refs) {
|
|
9
|
+
const map = /* @__PURE__ */ new Map();
|
|
10
|
+
for (const [refId, obj] of refs.entries()) if (obj !== null && typeof obj === "object") map.set(obj, refId);
|
|
11
|
+
return map;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Finds the refId for a Schema object using the reverse lookup map.
|
|
15
|
+
*
|
|
16
|
+
* In Colyseus 3.x, each Schema instance is assigned a unique numeric refId
|
|
17
|
+
* that remains stable across encode/decode cycles. This allows us to track
|
|
18
|
+
* object identity even when the JavaScript object references change.
|
|
19
|
+
*
|
|
20
|
+
* @param node - The Schema object to find the refId for
|
|
21
|
+
* @param ctx - The snapshot context with the reverse lookup map
|
|
22
|
+
* @returns The refId if found, or -1 if not found
|
|
23
|
+
*/
|
|
24
|
+
function findRefId(node, ctx) {
|
|
25
|
+
if (!ctx.refs) return -1;
|
|
26
|
+
if (!ctx.objectToRefId) ctx.objectToRefId = buildObjectToRefIdMap(ctx.refs);
|
|
27
|
+
return ctx.objectToRefId.get(node) ?? -1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates a snapshot of a MapSchema into a plain JavaScript object with structural sharing.
|
|
31
|
+
*/
|
|
32
|
+
function createSnapshotForMapSchema(node, previousResult, ctx) {
|
|
33
|
+
const snapshotted = {};
|
|
34
|
+
let hasChanged = previousResult === void 0;
|
|
35
|
+
for (const [key, value] of node) {
|
|
36
|
+
const snapshottedValue = createSnapshot(value, ctx);
|
|
37
|
+
snapshotted[key] = snapshottedValue;
|
|
38
|
+
if (!hasChanged && previousResult) {
|
|
39
|
+
if (!(key in previousResult) || previousResult[key] !== snapshottedValue) hasChanged = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!hasChanged && previousResult) {
|
|
43
|
+
if (Object.keys(previousResult).length !== Object.keys(snapshotted).length) hasChanged = true;
|
|
44
|
+
}
|
|
45
|
+
return hasChanged ? snapshotted : previousResult;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Creates a snapshot of an ArraySchema into a plain JavaScript array with structural sharing.
|
|
49
|
+
*/
|
|
50
|
+
function createSnapshotForArraySchema(node, previousResult, ctx) {
|
|
51
|
+
const length = node.length;
|
|
52
|
+
let hasChanged = !previousResult || !Array.isArray(previousResult) || length !== previousResult.length;
|
|
53
|
+
const snapshotted = new Array(length);
|
|
54
|
+
for (let i = 0; i < length; i++) {
|
|
55
|
+
const snapshottedValue = createSnapshot(node.at(i), ctx);
|
|
56
|
+
snapshotted[i] = snapshottedValue;
|
|
57
|
+
if (!hasChanged && previousResult && previousResult[i] !== snapshottedValue) hasChanged = true;
|
|
58
|
+
}
|
|
59
|
+
return hasChanged ? snapshotted : previousResult;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates a snapshot of a Schema object into a plain JavaScript object with structural sharing.
|
|
63
|
+
*/
|
|
64
|
+
function createSnapshotForSchema(node, previousResult, ctx) {
|
|
65
|
+
const snapshotted = {};
|
|
66
|
+
let hasChanged = previousResult === void 0;
|
|
67
|
+
const fieldDefinitions = node._definition?.fields;
|
|
68
|
+
if (fieldDefinitions) for (const fieldName in fieldDefinitions) {
|
|
69
|
+
const value = node[fieldName];
|
|
70
|
+
if (typeof value !== "function") {
|
|
71
|
+
const snapshottedValue = createSnapshot(value, ctx);
|
|
72
|
+
snapshotted[fieldName] = snapshottedValue;
|
|
73
|
+
if (!hasChanged && previousResult && previousResult[fieldName] !== snapshottedValue) hasChanged = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else for (const key in node) {
|
|
77
|
+
if (key.startsWith("_") || key.startsWith("$")) continue;
|
|
78
|
+
const value = node[key];
|
|
79
|
+
if (typeof value !== "function") {
|
|
80
|
+
const snapshottedValue = createSnapshot(value, ctx);
|
|
81
|
+
snapshotted[key] = snapshottedValue;
|
|
82
|
+
if (!hasChanged && previousResult && previousResult[key] !== snapshottedValue) hasChanged = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return hasChanged ? snapshotted : previousResult;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Recursively creates a snapshot of a Colyseus Schema node into plain JavaScript objects.
|
|
89
|
+
*
|
|
90
|
+
* This function implements structural sharing: if a node and all its descendants
|
|
91
|
+
* are unchanged from the previous render, the previous snapshot result is reused.
|
|
92
|
+
* This ensures referential equality for unchanged subtrees, allowing React memoization.
|
|
93
|
+
*
|
|
94
|
+
* @param node - The value to snapshot (may be a Schema, primitive, etc.)
|
|
95
|
+
* @param ctx - The snapshot context with refs and previous results
|
|
96
|
+
* @returns The snapshotted plain JavaScript value
|
|
97
|
+
*/
|
|
98
|
+
function createSnapshot(node, ctx) {
|
|
99
|
+
if (node === null || node === void 0 || typeof node !== "object") return node;
|
|
100
|
+
const refId = findRefId(node, ctx);
|
|
101
|
+
if (refId !== -1 && ctx.currentParentRefId !== -1) ctx.parentRefIdMap.set(refId, ctx.currentParentRefId);
|
|
102
|
+
if (refId !== -1 && ctx.currentResultsByRefId.has(refId)) return ctx.currentResultsByRefId.get(refId);
|
|
103
|
+
const previousResult = refId !== -1 ? ctx.previousResultsByRefId.get(refId) : void 0;
|
|
104
|
+
if (refId !== -1 && previousResult !== void 0 && !ctx.dirtyRefIds.has(refId)) {
|
|
105
|
+
ctx.currentResultsByRefId.set(refId, previousResult);
|
|
106
|
+
return previousResult;
|
|
107
|
+
}
|
|
108
|
+
const savedParentRefId = ctx.currentParentRefId;
|
|
109
|
+
ctx.currentParentRefId = refId;
|
|
110
|
+
let result;
|
|
111
|
+
if (node instanceof MapSchema) result = createSnapshotForMapSchema(node, previousResult, ctx);
|
|
112
|
+
else if (node instanceof ArraySchema) result = createSnapshotForArraySchema(node, previousResult, ctx);
|
|
113
|
+
else if (node instanceof Schema) result = createSnapshotForSchema(node, previousResult, ctx);
|
|
114
|
+
else result = node;
|
|
115
|
+
ctx.currentParentRefId = savedParentRefId;
|
|
116
|
+
if (refId !== -1) ctx.currentResultsByRefId.set(refId, result);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/schema/getOrCreateSubscription.ts
|
|
122
|
+
/** WeakMap to store subscriptions per room state instance */
|
|
123
|
+
const subscriptionsByState = /* @__PURE__ */ new WeakMap();
|
|
124
|
+
/**
|
|
125
|
+
* Gets or creates a subscription for the given room state.
|
|
126
|
+
*
|
|
127
|
+
* This function sets up change notification by wrapping the decoder's
|
|
128
|
+
* `triggerChanges` method to notify all subscribed React components.
|
|
129
|
+
*
|
|
130
|
+
* @param roomState - The Colyseus room state Schema instance
|
|
131
|
+
* @param decoder - The Colyseus decoder associated with the room
|
|
132
|
+
* @returns The subscription object for this room state
|
|
133
|
+
*/
|
|
134
|
+
function getOrCreateSubscription(roomState, decoder) {
|
|
135
|
+
let subscription = subscriptionsByState.get(roomState);
|
|
136
|
+
if (subscription) return subscription;
|
|
137
|
+
subscription = {
|
|
138
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
139
|
+
previousResultsByRefId: /* @__PURE__ */ new Map(),
|
|
140
|
+
dirtyRefIds: /* @__PURE__ */ new Set(),
|
|
141
|
+
parentRefIdMap: /* @__PURE__ */ new Map(),
|
|
142
|
+
objectToRefId: void 0,
|
|
143
|
+
cleanupCounter: 0
|
|
144
|
+
};
|
|
145
|
+
subscription.originalTrigger = decoder.triggerChanges?.bind(decoder);
|
|
146
|
+
decoder.triggerChanges = (changes) => {
|
|
147
|
+
if (subscription.originalTrigger) subscription.originalTrigger(changes);
|
|
148
|
+
const refs = decoder.root?.refs;
|
|
149
|
+
if (refs) {
|
|
150
|
+
subscription.objectToRefId = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const [refId, obj] of refs.entries()) if (obj !== null && typeof obj === "object") subscription.objectToRefId.set(obj, refId);
|
|
152
|
+
}
|
|
153
|
+
for (const change of changes) {
|
|
154
|
+
const refId = subscription.objectToRefId?.get(change.ref) ?? -1;
|
|
155
|
+
if (refId !== -1) {
|
|
156
|
+
let currentRefId = refId;
|
|
157
|
+
while (currentRefId !== void 0) {
|
|
158
|
+
subscription.dirtyRefIds.add(currentRefId);
|
|
159
|
+
currentRefId = subscription.parentRefIdMap.get(currentRefId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
subscription.listeners.forEach((callback) => callback());
|
|
164
|
+
};
|
|
165
|
+
subscriptionsByState.set(roomState, subscription);
|
|
166
|
+
return subscription;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/schema/useColyseusState.ts
|
|
171
|
+
/**
|
|
172
|
+
* React hook that provides immutable snapshots of Colyseus room state
|
|
173
|
+
* with structural sharing to minimize re-renders.
|
|
174
|
+
*
|
|
175
|
+
* This hook subscribes to state changes from the Colyseus decoder and
|
|
176
|
+
* produces plain JavaScript snapshots of the state. Unchanged portions
|
|
177
|
+
* of the state tree maintain referential equality between renders, enabling
|
|
178
|
+
* efficient React component updates.
|
|
179
|
+
*
|
|
180
|
+
* @template T - The root Schema type of the room state
|
|
181
|
+
* @template U - The selected portion of state (defaults to full state)
|
|
182
|
+
*
|
|
183
|
+
* @param roomState - The Colyseus room state Schema instance
|
|
184
|
+
* @param decoder - The Colyseus Decoder associated with the room
|
|
185
|
+
* @param selector - Optional function to select a portion of the state
|
|
186
|
+
*
|
|
187
|
+
* @returns The snapshotted, immutable state
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```tsx
|
|
191
|
+
* // Use the full state
|
|
192
|
+
* const state = useColyseusState(room.state, decoder);
|
|
193
|
+
*
|
|
194
|
+
* // Use with a selector to only subscribe to part of the state
|
|
195
|
+
* const players = useColyseusState(room.state, decoder, (s) => s.players);
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
function useColyseusState(roomState, decoder, selector = (s) => s) {
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (roomState && decoder) getOrCreateSubscription(roomState, decoder);
|
|
201
|
+
}, [roomState, decoder]);
|
|
202
|
+
const getSnapshot = () => {
|
|
203
|
+
if (!roomState || !decoder) return;
|
|
204
|
+
const subscription = getOrCreateSubscription(roomState, decoder);
|
|
205
|
+
const selectedState = selector(roomState);
|
|
206
|
+
const ctx = {
|
|
207
|
+
refs: decoder.root?.refs,
|
|
208
|
+
objectToRefId: subscription.objectToRefId,
|
|
209
|
+
previousResultsByRefId: subscription.previousResultsByRefId,
|
|
210
|
+
currentResultsByRefId: /* @__PURE__ */ new Map(),
|
|
211
|
+
dirtyRefIds: subscription.dirtyRefIds,
|
|
212
|
+
parentRefIdMap: subscription.parentRefIdMap,
|
|
213
|
+
currentParentRefId: -1
|
|
214
|
+
};
|
|
215
|
+
const result = createSnapshot(selectedState, ctx);
|
|
216
|
+
for (const [refId, value] of ctx.currentResultsByRefId) subscription.previousResultsByRefId.set(refId, value);
|
|
217
|
+
subscription.objectToRefId = ctx.objectToRefId;
|
|
218
|
+
subscription.dirtyRefIds.clear();
|
|
219
|
+
if (++subscription.cleanupCounter >= 100 && ctx.refs) {
|
|
220
|
+
subscription.cleanupCounter = 0;
|
|
221
|
+
for (const refId of subscription.previousResultsByRefId.keys()) if (!ctx.refs.has(refId)) {
|
|
222
|
+
subscription.previousResultsByRefId.delete(refId);
|
|
223
|
+
subscription.parentRefIdMap.delete(refId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
};
|
|
228
|
+
const subscribe = (callback) => {
|
|
229
|
+
if (!roomState || !decoder) return () => {};
|
|
230
|
+
const subscription = getOrCreateSubscription(roomState, decoder);
|
|
231
|
+
subscription.listeners.add(callback);
|
|
232
|
+
return () => subscription.listeners.delete(callback);
|
|
233
|
+
};
|
|
234
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/schema/useRoomState.ts
|
|
239
|
+
/**
|
|
240
|
+
* React hook that provides immutable snapshots of Colyseus room state
|
|
241
|
+
* with structural sharing to minimize re-renders.
|
|
242
|
+
*
|
|
243
|
+
* This hook subscribes to state changes from the room's Colyseus decoder
|
|
244
|
+
* and produces plain JavaScript snapshots of the state. Unchanged portions
|
|
245
|
+
* of the state tree maintain referential equality between renders, enabling
|
|
246
|
+
* efficient React component updates.
|
|
247
|
+
*
|
|
248
|
+
* @template T - The root Schema type of the room state
|
|
249
|
+
* @template U - The selected portion of state (defaults to full state)
|
|
250
|
+
*
|
|
251
|
+
* @param room - The Colyseus room instance whose state should be snapshotted
|
|
252
|
+
* @param selector - Optional function to select a portion of the state
|
|
253
|
+
*
|
|
254
|
+
* @returns The snapshotted, immutable state
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```tsx
|
|
258
|
+
* // Use the full state
|
|
259
|
+
* const state = useRoomState(room);
|
|
260
|
+
*
|
|
261
|
+
* // Use with a selector to only subscribe to part of the state
|
|
262
|
+
* const players = useRoomState(room, (s) => s.players);
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
function useRoomState(room, selector = (s) => s) {
|
|
266
|
+
const decoder = room.serializer.decoder;
|
|
267
|
+
return useColyseusState(room.state, decoder, selector);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
//#endregion
|
|
271
|
+
export { useRoomState };
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@colyseus/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "tsdown --watch --format esm,cjs",
|
|
7
|
+
"build": "tsdown --format esm,cjs",
|
|
8
|
+
"lint": "eslint .",
|
|
9
|
+
"test": "vitest",
|
|
10
|
+
"prepublishOnly": "npm run build"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.mts",
|
|
15
|
+
"import": "./dist/index.mjs",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@colyseus/schema": "^4.0.8",
|
|
24
|
+
"@colyseus/sdk": "^0.17.26",
|
|
25
|
+
"react": "^18.3.1",
|
|
26
|
+
"react-dom": "^18.3.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@colyseus/schema": "^4.0.8",
|
|
30
|
+
"@colyseus/sdk": "^0.17.26",
|
|
31
|
+
"@eslint/js": "^9.9.0",
|
|
32
|
+
"@testing-library/react": "^16.3.1",
|
|
33
|
+
"@types/react": "^18.3.3",
|
|
34
|
+
"@types/react-dom": "^18.3.0",
|
|
35
|
+
"buffer": "^6.0.3",
|
|
36
|
+
"eslint": "^9.9.0",
|
|
37
|
+
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
|
38
|
+
"eslint-plugin-react-refresh": "^0.4.9",
|
|
39
|
+
"globals": "^15.9.0",
|
|
40
|
+
"happy-dom": "^20.0.11",
|
|
41
|
+
"react": "^18.3.1",
|
|
42
|
+
"react-dom": "^18.3.1",
|
|
43
|
+
"reflect-metadata": "^0.2.2",
|
|
44
|
+
"tsdown": "^0.20.1",
|
|
45
|
+
"typescript": "^5.5.3",
|
|
46
|
+
"typescript-eslint": "^8.0.1",
|
|
47
|
+
"vitest": "^3.2.4"
|
|
48
|
+
}
|
|
49
|
+
}
|