@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 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;
@@ -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 };
@@ -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
+ }