@pylonsync/react 0.3.213 → 0.3.216

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.213",
6
+ "version": "0.3.216",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
@@ -12,8 +12,8 @@
12
12
  "check": "tsc -p tsconfig.json --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@pylonsync/sdk": "0.3.213",
16
- "@pylonsync/sync": "0.3.213"
15
+ "@pylonsync/sdk": "0.3.216",
16
+ "@pylonsync/sync": "0.3.216"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": ">=19.0.0"
package/src/db.ts CHANGED
@@ -75,7 +75,11 @@ export function init(config?: Partial<SyncEngineConfig> & { baseUrl?: string })
75
75
  });
76
76
  }
77
77
 
78
- function getSync(): SyncEngine {
78
+ /** Module-internal accessor for the global sync engine. Exported so
79
+ * hooks living outside this file (e.g. `useRoom`) can share the same
80
+ * engine instance and benefit from the same lazy-start / lazy-init
81
+ * semantics — without re-implementing the resolution rules. */
82
+ export function getSync(): SyncEngine {
79
83
  if (!_sync) {
80
84
  // Lazy fallback for callers that never invoked init(). Same
81
85
  // resolution rules as init: browser → window.location.origin,
@@ -0,0 +1,360 @@
1
+ // useRoom — WebSocket push path coverage.
2
+ //
3
+ // The legacy polling-only path is covered in `useRoom.test.ts`; this
4
+ // file focuses on the v0.3.216 push protocol integration:
5
+ //
6
+ // - WS connected + engine wired in → registry attaches to
7
+ // engine.subscribeRoom AND does NOT start a polling interval
8
+ // - inbound room-snapshot via the engine flows through to subscriber
9
+ // callbacks
10
+ // - inbound room-update action:join updates peers in the registry
11
+ // - WS not connected → polling fallback fires (existing behaviour
12
+ // preserved when the engine reports the WS down)
13
+ // - StrictMode double-mount with WS → still one engine subscription
14
+ //
15
+ // We drive the registry directly via `__roomRegistryInternals` and feed
16
+ // in a controllable fake engine — no React renderer involved, no real
17
+ // network. The fake engine matches the surface the hook actually
18
+ // consumes (subscribeRoom, getRoomMembers, getRoomError,
19
+ // isWebSocketConnected, connectionStatus, store.subscribe).
20
+
21
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
+
23
+ import { __roomRegistryInternals } from "./useRoom";
24
+
25
+ // --- fetch stub (mirrors useRoom.test.ts) ------------------------------
26
+
27
+ interface RecordedRequest {
28
+ url: string;
29
+ method: string;
30
+ }
31
+
32
+ let recorded: RecordedRequest[];
33
+ let originalFetch: typeof globalThis.fetch;
34
+
35
+ function installFetchStub(): void {
36
+ recorded = [];
37
+ originalFetch = globalThis.fetch;
38
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
39
+ const url = typeof input === "string" ? input : input.toString();
40
+ const method = (init?.method ?? "GET").toUpperCase();
41
+ recorded.push({ url, method });
42
+ return new Response(JSON.stringify({ snapshot: { peers: [] }, members: [] }), {
43
+ status: 200,
44
+ headers: { "content-type": "application/json" },
45
+ });
46
+ }) as typeof fetch;
47
+ }
48
+
49
+ function restoreFetch(): void {
50
+ globalThis.fetch = originalFetch;
51
+ }
52
+
53
+ function isHeartbeat(r: RecordedRequest): boolean {
54
+ return r.method === "GET" && /\/api\/rooms\/[^/]+$/.test(r.url);
55
+ }
56
+
57
+ // --- fake engine -------------------------------------------------------
58
+
59
+ interface FakeRoomEntry {
60
+ callbacks: Set<() => void>;
61
+ members: Array<{ user_id: string; joined_at: string; data?: any }> | null;
62
+ error: { code: string; message?: string } | null;
63
+ }
64
+
65
+ interface FakeEngine {
66
+ // Surface that useRoom actually pokes:
67
+ subscribeRoom: (roomId: string, cb: () => void) => () => void;
68
+ getRoomMembers: (roomId: string) =>
69
+ | Array<{ user_id: string; joined_at: string; data?: any }>
70
+ | null;
71
+ getRoomError: (roomId: string) => { code: string; message?: string } | null;
72
+ isWebSocketConnected: () => boolean;
73
+ connectionStatus: () => string;
74
+ // The store.subscribe surface — useRoom subscribes to connection-status
75
+ // changes via it.
76
+ store: {
77
+ subscribe: (fn: () => void) => () => void;
78
+ };
79
+
80
+ // Test helpers — not on the real engine.
81
+ _wsOpen: boolean;
82
+ _statusListeners: Set<() => void>;
83
+ _rooms: Map<string, FakeRoomEntry>;
84
+ _subscribeCalls: string[];
85
+ _unsubscribeCalls: string[];
86
+ setWsConnected(open: boolean): void;
87
+ pushSnapshot(roomId: string, members: any[]): void;
88
+ pushUpdate(
89
+ roomId: string,
90
+ action: "join" | "leave" | "presence" | "broadcast",
91
+ member?: any,
92
+ ): void;
93
+ pushError(roomId: string, code: string, message?: string): void;
94
+ }
95
+
96
+ function makeFakeEngine(): FakeEngine {
97
+ const engine: FakeEngine = {
98
+ _wsOpen: true,
99
+ _statusListeners: new Set(),
100
+ _rooms: new Map(),
101
+ _subscribeCalls: [],
102
+ _unsubscribeCalls: [],
103
+
104
+ isWebSocketConnected: () => engine._wsOpen,
105
+ connectionStatus: () => (engine._wsOpen ? "connected" : "reconnecting"),
106
+ store: {
107
+ subscribe: (fn) => {
108
+ engine._statusListeners.add(fn);
109
+ return () => engine._statusListeners.delete(fn);
110
+ },
111
+ },
112
+ subscribeRoom: (roomId, cb) => {
113
+ engine._subscribeCalls.push(roomId);
114
+ let entry = engine._rooms.get(roomId);
115
+ if (!entry) {
116
+ entry = { callbacks: new Set(), members: null, error: null };
117
+ engine._rooms.set(roomId, entry);
118
+ }
119
+ entry.callbacks.add(cb);
120
+ // Fire one tick if there's already cached state (registry late-
121
+ // subscriber path).
122
+ if (entry.members !== null || entry.error !== null) cb();
123
+ return () => {
124
+ const e = engine._rooms.get(roomId);
125
+ if (!e) return;
126
+ e.callbacks.delete(cb);
127
+ if (e.callbacks.size === 0) {
128
+ engine._rooms.delete(roomId);
129
+ engine._unsubscribeCalls.push(roomId);
130
+ }
131
+ };
132
+ },
133
+ getRoomMembers: (roomId) => engine._rooms.get(roomId)?.members ?? null,
134
+ getRoomError: (roomId) => engine._rooms.get(roomId)?.error ?? null,
135
+
136
+ setWsConnected: (open) => {
137
+ engine._wsOpen = open;
138
+ for (const fn of engine._statusListeners) fn();
139
+ },
140
+ pushSnapshot: (roomId, members) => {
141
+ const entry = engine._rooms.get(roomId);
142
+ if (!entry) return;
143
+ entry.members = members;
144
+ entry.error = null;
145
+ for (const cb of entry.callbacks) cb();
146
+ },
147
+ pushUpdate: (roomId, action, member) => {
148
+ const entry = engine._rooms.get(roomId);
149
+ if (!entry) return;
150
+ if (entry.members === null) entry.members = [];
151
+ if (action === "join" && member) {
152
+ const filtered = entry.members.filter(
153
+ (m) => m.user_id !== member.user_id,
154
+ );
155
+ filtered.push(member);
156
+ entry.members = filtered;
157
+ } else if (action === "leave" && member) {
158
+ entry.members = entry.members.filter(
159
+ (m) => m.user_id !== member.user_id,
160
+ );
161
+ }
162
+ for (const cb of entry.callbacks) cb();
163
+ },
164
+ pushError: (roomId, code, message) => {
165
+ const entry = engine._rooms.get(roomId);
166
+ if (!entry) return;
167
+ entry.error = { code, message };
168
+ for (const cb of entry.callbacks) cb();
169
+ },
170
+ };
171
+ return engine;
172
+ }
173
+
174
+ // --- harness -----------------------------------------------------------
175
+
176
+ const BASE = "http://stub.invalid";
177
+ const ROOM = "channel:foo";
178
+ const USER = "user-1";
179
+ const TOKEN = "tok-1";
180
+
181
+ async function flush(ms = 5): Promise<void> {
182
+ for (let i = 0; i < 5; i++) await Promise.resolve();
183
+ if (ms > 0) await new Promise((res) => setTimeout(res, ms));
184
+ for (let i = 0; i < 5; i++) await Promise.resolve();
185
+ }
186
+
187
+ beforeEach(() => {
188
+ installFetchStub();
189
+ __roomRegistryInternals.reset();
190
+ });
191
+
192
+ afterEach(() => {
193
+ __roomRegistryInternals.reset();
194
+ restoreFetch();
195
+ });
196
+
197
+ // --- tests -------------------------------------------------------------
198
+
199
+ describe("useRoom WS push path", () => {
200
+ test("WS connected + engine wired → subscribeRoom called, no polling interval", async () => {
201
+ const engine = makeFakeEngine();
202
+ const room = __roomRegistryInternals.acquire(
203
+ BASE,
204
+ ROOM,
205
+ USER,
206
+ TOKEN,
207
+ {},
208
+ 25, // would tick every 25ms IF polling were active
209
+ engine as any,
210
+ );
211
+ expect(engine._subscribeCalls).toEqual([ROOM]);
212
+ expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
213
+ expect(__roomRegistryInternals.isPolling(room)).toBe(false);
214
+
215
+ // Wait through what WOULD have been multiple polling ticks. No
216
+ // GET /api/rooms/<room> should ever land.
217
+ await flush(80);
218
+ expect(recorded.filter(isHeartbeat).length).toBe(0);
219
+
220
+ // Engine pushes a snapshot — the hook's local cache should reflect
221
+ // it once the registry's pulse runs.
222
+ engine.pushSnapshot(ROOM, [
223
+ { user_id: "alice", joined_at: "t1" },
224
+ { user_id: USER, joined_at: "t2" },
225
+ ]);
226
+ await flush();
227
+ // The registry filters out the current user → only alice should
228
+ // remain.
229
+ expect(room.peers.map((p) => p.user_id)).toEqual(["alice"]);
230
+ });
231
+
232
+ test("inbound room-update action:join updates peers", async () => {
233
+ const engine = makeFakeEngine();
234
+ const room = __roomRegistryInternals.acquire(
235
+ BASE,
236
+ ROOM,
237
+ USER,
238
+ TOKEN,
239
+ {},
240
+ 1_000,
241
+ engine as any,
242
+ );
243
+ // Let the HTTP join settle first so its (empty) snapshot doesn't
244
+ // race the WS-pushed members. In production the WS snapshot
245
+ // typically lands shortly AFTER the join HTTP response — this
246
+ // ordering matches that.
247
+ await flush();
248
+ engine.pushSnapshot(ROOM, []);
249
+ engine.pushUpdate(ROOM, "join", { user_id: "bob", joined_at: "t3" });
250
+ await flush();
251
+ expect(room.peers.map((p) => p.user_id)).toEqual(["bob"]);
252
+ });
253
+
254
+ test("server pushes NOT_IN_ROOM error → surfaces via room.error, isConnected false, no retry", async () => {
255
+ const engine = makeFakeEngine();
256
+ const room = __roomRegistryInternals.acquire(
257
+ BASE,
258
+ ROOM,
259
+ USER,
260
+ TOKEN,
261
+ {},
262
+ 1_000,
263
+ engine as any,
264
+ );
265
+ // Let the HTTP join settle first so its success doesn't clobber
266
+ // the WS-pushed error state.
267
+ await flush();
268
+ engine.pushError(ROOM, "NOT_IN_ROOM", "not a member");
269
+ await flush();
270
+ expect(typeof room.error).toBe("string");
271
+ expect(room.error ?? "").toContain("not a member");
272
+ expect(room.isConnected).toBe(false);
273
+ // Snapshot-timeout fallback should NOT have engaged (error path
274
+ // cancels the backwards-compat timer).
275
+ await flush(80);
276
+ expect(__roomRegistryInternals.isPolling(room)).toBe(false);
277
+ });
278
+
279
+ test("WS down → polling fallback fires; reconnect promotes back to push", async () => {
280
+ const engine = makeFakeEngine();
281
+ engine.setWsConnected(false);
282
+ const room = __roomRegistryInternals.acquire(
283
+ BASE,
284
+ ROOM,
285
+ USER,
286
+ TOKEN,
287
+ {},
288
+ 25,
289
+ engine as any,
290
+ );
291
+ // No WS — no subscribeRoom call, polling active.
292
+ expect(engine._subscribeCalls).toEqual([]);
293
+ expect(__roomRegistryInternals.isPolling(room)).toBe(true);
294
+
295
+ await flush(80);
296
+ expect(recorded.filter(isHeartbeat).length).toBeGreaterThan(0);
297
+
298
+ // Flip to connected → registry promotes to push, polling stops.
299
+ engine.setWsConnected(true);
300
+ await flush();
301
+ expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
302
+ expect(__roomRegistryInternals.isPolling(room)).toBe(false);
303
+ expect(engine._subscribeCalls).toEqual([ROOM]);
304
+ });
305
+
306
+ test("backwards-compat: WS connected but no snapshot in 2s → fall back to polling", async () => {
307
+ const engine = makeFakeEngine();
308
+ // Crucial: engine.subscribeRoom returns successfully but never
309
+ // pushes a snapshot. That mirrors talking to a pre-v0.3.214 server
310
+ // which silently drops the room-subscribe frame.
311
+ const room = __roomRegistryInternals.acquire(
312
+ BASE,
313
+ ROOM,
314
+ USER,
315
+ TOKEN,
316
+ {},
317
+ 25,
318
+ engine as any,
319
+ );
320
+ expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
321
+ expect(__roomRegistryInternals.isPolling(room)).toBe(false);
322
+
323
+ // The push-snapshot timeout is 2s in the implementation. Wait
324
+ // long enough for it to fire AND let one polling tick happen.
325
+ await flush(2_100);
326
+ expect(__roomRegistryInternals.isWsAttached(room)).toBe(false);
327
+ expect(__roomRegistryInternals.isPolling(room)).toBe(true);
328
+ expect(recorded.filter(isHeartbeat).length).toBeGreaterThan(0);
329
+ });
330
+
331
+ test("StrictMode double-mount on WS path: still one engine.subscribeRoom call", async () => {
332
+ const engine = makeFakeEngine();
333
+ const r1 = __roomRegistryInternals.acquire(
334
+ BASE,
335
+ ROOM,
336
+ USER,
337
+ TOKEN,
338
+ {},
339
+ 1_000,
340
+ engine as any,
341
+ );
342
+ const r2 = __roomRegistryInternals.acquire(
343
+ BASE,
344
+ ROOM,
345
+ USER,
346
+ TOKEN,
347
+ {},
348
+ 1_000,
349
+ engine as any,
350
+ );
351
+ expect(r1).toBe(r2);
352
+ // Registry dedup → only one acquire path → only one subscribe.
353
+ expect(engine._subscribeCalls).toEqual([ROOM]);
354
+ __roomRegistryInternals.release(r1);
355
+ __roomRegistryInternals.release(r2);
356
+ await flush(20);
357
+ // One subscribe, one unsubscribe — no fanout.
358
+ expect(engine._unsubscribeCalls).toEqual([ROOM]);
359
+ });
360
+ });
package/src/useRoom.ts CHANGED
@@ -1,8 +1,20 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef } from 'react';
4
- import { pylonFetch } from '@pylonsync/sync';
4
+ import { pylonFetch, type RoomMember, type SyncEngine } from '@pylonsync/sync';
5
5
  import { getBaseUrl, getReactStorage, storageKey } from './index';
6
+ import { getSync } from './db';
7
+
8
+ /** Project an engine RoomMember (data optional) into a RoomPeer (data
9
+ * required). The wire frame always carries a `data` slot — empty objects
10
+ * are valid — so the normalization is purely a type contract. */
11
+ function memberToPeer(m: RoomMember): RoomPeer {
12
+ return {
13
+ user_id: m.user_id,
14
+ joined_at: m.joined_at,
15
+ data: m.data ?? {},
16
+ };
17
+ }
6
18
 
7
19
  // ---------------------------------------------------------------------------
8
20
  // Room types
@@ -26,7 +38,12 @@ export interface UseRoomOptions {
26
38
  token?: string;
27
39
  /** Initial presence data sent on join. */
28
40
  initialPresence?: Record<string, any>;
29
- /** How often to poll for peer updates (ms). Defaults to 5 000. */
41
+ /**
42
+ * How often (ms) to poll for peer updates WHEN the WebSocket
43
+ * push channel is unavailable. With a connected WebSocket the SDK
44
+ * uses `room-subscribe` push and the polling timer never fires.
45
+ * Defaults to 5_000.
46
+ */
30
47
  heartbeatInterval?: number;
31
48
  }
32
49
 
@@ -54,7 +71,8 @@ export interface UseRoomReturn {
54
71
  // composer, …). Without deduping that explodes into N joins on mount, N
55
72
  // leaves on unmount, and N×interval heartbeat polls every tick. This
56
73
  // registry collapses all calls with the same identity tuple into a single
57
- // network footprint: one join, one interval, one leave.
74
+ // network footprint: one join, one interval (polling-fallback only),
75
+ // one leave.
58
76
  //
59
77
  // Identity key includes everything that would change the network shape:
60
78
  // baseUrl + token (different server / auth) + roomId + userId.
@@ -63,9 +81,27 @@ export interface UseRoomReturn {
63
81
  // teardown is scheduled on a microtask. If the same hook re-mounts
64
82
  // synchronously in the same tick (StrictMode double-mount, fast tab
65
83
  // switch, parent re-key), the pending teardown is cancelled and the room
66
- // stays alive. The existing `joined` race-fix from the in-effect
67
- // implementation is preserved here too: leave only fires once the
68
- // original join has actually landed on the server.
84
+ // stays alive. The `joined` race-fix from the in-effect implementation
85
+ // is preserved: leave only fires once the original join has actually
86
+ // landed on the server.
87
+ //
88
+ // WS-push integration (v0.3.216): when the sync engine's transport is
89
+ // websocket AND currently connected, the registry attaches to
90
+ // `engine.subscribeRoom(roomId, cb)` and stops the polling interval.
91
+ // The engine's room registry handles the wire-level refcount
92
+ // independently. When the WS isn't available (SSE transport, polling
93
+ // transport, OR WS not yet connected), the registry runs the legacy 5s
94
+ // polling loop. We re-evaluate on connection-status changes so a tab
95
+ // that mounts while offline polls until the WS comes up, then quietly
96
+ // switches to push.
97
+
98
+ /** Window (ms) we wait for a `room-snapshot` after sending the first
99
+ * `room-subscribe`. If nothing lands by then we assume the server is
100
+ * pre-v0.3.214 and doesn't speak the push protocol — fall back to
101
+ * polling. Sized generously enough to absorb a slow link + the
102
+ * server's RoomManager lookup; short enough that a misconfigured
103
+ * setup recovers without the user noticing. */
104
+ const PUSH_SNAPSHOT_TIMEOUT_MS = 2_000;
69
105
 
70
106
  interface SharedRoom {
71
107
  /** Number of live React hooks holding this room. */
@@ -78,7 +114,7 @@ interface SharedRoom {
78
114
  error: string | null;
79
115
  /** Last presence data the caller pushed via setPresence. */
80
116
  presence: Record<string, any>;
81
- /** Active heartbeat poll. */
117
+ /** Active heartbeat poll (only set when polling fallback is active). */
82
118
  interval: ReturnType<typeof setInterval> | null;
83
119
  /** Pending teardown — set when refcount hit 0 but we haven't actually
84
120
  * fired leave yet, to absorb StrictMode mount/unmount/mount races. */
@@ -91,6 +127,22 @@ interface SharedRoom {
91
127
  roomId: string;
92
128
  userId: string;
93
129
  heartbeatInterval: number;
130
+ /** Sync engine instance the room is attached to (for WS push).
131
+ * `null` when none provided / running in SSR or test harness that
132
+ * never invoked init(). */
133
+ engine: SyncEngine | null;
134
+ /** Unsubscribe handle returned by engine.subscribeRoom. `null` when
135
+ * we're polling. Mutually exclusive with `interval` — exactly one
136
+ * is set whenever the room is live. */
137
+ wsUnsubscribe: (() => void) | null;
138
+ /** Timer that fires PUSH_SNAPSHOT_TIMEOUT_MS after we send the first
139
+ * room-subscribe. If a snapshot lands first we cancel it; if it
140
+ * fires we assume the server doesn't speak the push protocol and
141
+ * fall back to polling. */
142
+ pushSnapshotTimer: ReturnType<typeof setTimeout> | null;
143
+ /** Connection-status unsubscribe — fires whenever the engine's
144
+ * transport state changes so we can flip between push and polling. */
145
+ connectionStatusUnsubscribe: (() => void) | null;
94
146
  }
95
147
 
96
148
  const rooms = new Map<string, SharedRoom>();
@@ -112,6 +164,12 @@ function transportFor(room: SharedRoom) {
112
164
  return { baseUrl: room.baseUrl, token: room.token };
113
165
  }
114
166
 
167
+ function roomKeyFor(room: SharedRoom): string {
168
+ return roomKey(room.baseUrl, room.roomId, room.userId, room.token);
169
+ }
170
+
171
+ /** Begin the 5s GET /api/rooms/<room> polling loop. Idempotent — if
172
+ * a polling timer is already running, no-op. */
115
173
  function startHeartbeat(room: SharedRoom): void {
116
174
  if (room.interval) return;
117
175
  room.interval = setInterval(async () => {
@@ -133,14 +191,124 @@ function startHeartbeat(room: SharedRoom): void {
133
191
  }, room.heartbeatInterval);
134
192
  }
135
193
 
136
- function roomKeyFor(room: SharedRoom): string {
137
- return roomKey(room.baseUrl, room.roomId, room.userId, room.token);
194
+ /** Stop the polling timer if running. */
195
+ function stopHeartbeat(room: SharedRoom): void {
196
+ if (room.interval) {
197
+ clearInterval(room.interval);
198
+ room.interval = null;
199
+ }
200
+ }
201
+
202
+ /** Attach to the engine's WS room subscription. Pulls in the current
203
+ * snapshot (or, when one lands, replaces the cached peers). If the
204
+ * initial subscribe doesn't surface a snapshot within
205
+ * PUSH_SNAPSHOT_TIMEOUT_MS, fall back to polling — that's the
206
+ * backwards-compat path for pre-v0.3.214 servers. */
207
+ function startWsPush(room: SharedRoom): void {
208
+ const engine = room.engine;
209
+ if (!engine) return;
210
+ if (room.wsUnsubscribe) return; // already attached
211
+ stopHeartbeat(room);
212
+ room.wsUnsubscribe = engine.subscribeRoom(room.roomId, () => {
213
+ if (!rooms.has(roomKeyFor(room))) return;
214
+ const members = engine.getRoomMembers(room.roomId);
215
+ const err = engine.getRoomError(room.roomId);
216
+ if (err) {
217
+ // Snapshot landing implies the server speaks the push protocol.
218
+ // Cancel the backwards-compat timer; we're staying on push.
219
+ if (room.pushSnapshotTimer) {
220
+ clearTimeout(room.pushSnapshotTimer);
221
+ room.pushSnapshotTimer = null;
222
+ }
223
+ room.error =
224
+ err.code === 'NOT_IN_ROOM'
225
+ ? 'You are not a member of this room.'
226
+ : (err.message ?? err.code);
227
+ room.isConnected = false;
228
+ notify(room);
229
+ return;
230
+ }
231
+ if (members !== null) {
232
+ if (room.pushSnapshotTimer) {
233
+ clearTimeout(room.pushSnapshotTimer);
234
+ room.pushSnapshotTimer = null;
235
+ }
236
+ room.peers = members
237
+ .filter((m) => m.user_id !== room.userId)
238
+ .map(memberToPeer);
239
+ room.isConnected = true;
240
+ room.error = null;
241
+ notify(room);
242
+ }
243
+ });
244
+ // Cached state inside the engine's registry may already be populated
245
+ // (we are a late subscriber to an existing engine-level sub). Pull
246
+ // it once so the hook re-renders immediately without waiting for the
247
+ // next push.
248
+ const cachedMembers = engine.getRoomMembers(room.roomId);
249
+ if (cachedMembers !== null) {
250
+ room.peers = cachedMembers
251
+ .filter((m) => m.user_id !== room.userId)
252
+ .map(memberToPeer);
253
+ room.isConnected = true;
254
+ notify(room);
255
+ } else if (!room.pushSnapshotTimer) {
256
+ // Arm the backwards-compat timer. If no snapshot lands in 2s, the
257
+ // server is presumed pre-v0.3.214 and we revert to polling. The
258
+ // timer is cancelled the moment a real snapshot OR a real error
259
+ // lands (both paths above clear it).
260
+ room.pushSnapshotTimer = setTimeout(() => {
261
+ room.pushSnapshotTimer = null;
262
+ if (!rooms.has(roomKeyFor(room))) return;
263
+ // No snapshot — tear down the WS sub and fall back to polling.
264
+ // The engine's room-unsubscribe ships harmlessly even on old
265
+ // servers (unknown frames are ignored).
266
+ if (room.wsUnsubscribe) {
267
+ room.wsUnsubscribe();
268
+ room.wsUnsubscribe = null;
269
+ }
270
+ startHeartbeat(room);
271
+ }, PUSH_SNAPSHOT_TIMEOUT_MS);
272
+ }
273
+ }
274
+
275
+ /** Detach the engine WS sub if attached. */
276
+ function stopWsPush(room: SharedRoom): void {
277
+ if (room.wsUnsubscribe) {
278
+ room.wsUnsubscribe();
279
+ room.wsUnsubscribe = null;
280
+ }
281
+ if (room.pushSnapshotTimer) {
282
+ clearTimeout(room.pushSnapshotTimer);
283
+ room.pushSnapshotTimer = null;
284
+ }
285
+ }
286
+
287
+ /** Pick the right delivery channel for the room based on the engine's
288
+ * current transport state. Called on initial acquire AND whenever the
289
+ * engine's connection status changes — a room that started on polling
290
+ * promotes to push the moment the WS opens, and vice versa. */
291
+ function evaluateDelivery(room: SharedRoom): void {
292
+ const engine = room.engine;
293
+ if (!engine) {
294
+ // No engine wired in — pure HTTP polling mode. Idempotent.
295
+ stopWsPush(room);
296
+ startHeartbeat(room);
297
+ return;
298
+ }
299
+ if (engine.isWebSocketConnected()) {
300
+ startWsPush(room);
301
+ } else {
302
+ stopWsPush(room);
303
+ startHeartbeat(room);
304
+ }
138
305
  }
139
306
 
140
307
  function joinRoom(room: SharedRoom): void {
141
308
  // Fire-and-forget — the join promise resolves into the shared state
142
309
  // and notifies all subscribers. Late subscribers either pick up the
143
- // already-resolved snapshot from `room.peers` or the next heartbeat.
310
+ // already-resolved snapshot from `room.peers` or the next push /
311
+ // heartbeat.
144
312
  pylonFetch<{ snapshot?: { peers?: RoomPeer[] } }>(
145
313
  transportFor(room),
146
314
  '/api/rooms/join',
@@ -188,6 +356,7 @@ function acquireRoom(
188
356
  token: string | undefined,
189
357
  initialPresence: Record<string, any>,
190
358
  heartbeatInterval: number,
359
+ engine: SyncEngine | null,
191
360
  ): SharedRoom {
192
361
  const key = roomKey(baseUrl, roomId, userId, token);
193
362
  let room = rooms.get(key);
@@ -217,10 +386,23 @@ function acquireRoom(
217
386
  roomId,
218
387
  userId,
219
388
  heartbeatInterval,
389
+ engine,
390
+ wsUnsubscribe: null,
391
+ pushSnapshotTimer: null,
392
+ connectionStatusUnsubscribe: null,
220
393
  };
221
394
  rooms.set(key, room);
222
395
  joinRoom(room);
223
- startHeartbeat(room);
396
+ // Watch connection-status so the room flips from polling to push (or
397
+ // back) as the WS opens / drops. Only useful when an engine is wired
398
+ // in; without one, the hook is HTTP-only and the status never matters.
399
+ if (engine) {
400
+ room.connectionStatusUnsubscribe = subscribeConnectionStatus(engine, () => {
401
+ const r = rooms.get(roomKeyFor(room!));
402
+ if (r) evaluateDelivery(r);
403
+ });
404
+ }
405
+ evaluateDelivery(room);
224
406
  return room;
225
407
  }
226
408
 
@@ -240,9 +422,11 @@ function releaseRoom(room: SharedRoom): void {
240
422
  room.pendingTeardown = null;
241
423
  return;
242
424
  }
243
- if (room.interval) {
244
- clearInterval(room.interval);
245
- room.interval = null;
425
+ stopHeartbeat(room);
426
+ stopWsPush(room);
427
+ if (room.connectionStatusUnsubscribe) {
428
+ room.connectionStatusUnsubscribe();
429
+ room.connectionStatusUnsubscribe = null;
246
430
  }
247
431
  rooms.delete(roomKeyFor(room));
248
432
  room.pendingTeardown = null;
@@ -250,6 +434,37 @@ function releaseRoom(room: SharedRoom): void {
250
434
  }, 0);
251
435
  }
252
436
 
437
+ /**
438
+ * Best-effort subscription to the engine's connection-status stream.
439
+ * The sync engine notifies via `store.notify()` on every status change;
440
+ * we listen on the LocalStore's subscribe surface and poll the status
441
+ * inside the callback. Returns an unsubscribe.
442
+ *
443
+ * This module deliberately doesn't import `useSyncStatus` (a React
444
+ * hook) — the registry runs outside React, and the only thing we need
445
+ * is a notify-on-change pulse. The store's subscribe method gives us
446
+ * exactly that without pulling in another dep.
447
+ */
448
+ function subscribeConnectionStatus(
449
+ engine: SyncEngine,
450
+ cb: () => void,
451
+ ): () => void {
452
+ // The engine's LocalStore notify() fires on every status flip
453
+ // (setConnectionStatus calls store.notify). Subscribing to that is
454
+ // a cheap way to observe transport transitions.
455
+ const store = (engine as unknown as { store?: { subscribe: (fn: () => void) => () => void } }).store;
456
+ if (!store?.subscribe) {
457
+ return () => {};
458
+ }
459
+ let lastStatus = engine.connectionStatus();
460
+ return store.subscribe(() => {
461
+ const nextStatus = engine.connectionStatus();
462
+ if (nextStatus === lastStatus) return;
463
+ lastStatus = nextStatus;
464
+ cb();
465
+ });
466
+ }
467
+
253
468
  // Exported for tests — lets the regression test reset registry state
254
469
  // between cases so leaked rooms from one test can't bleed into the next,
255
470
  // and drive the refcount through the same code path the hook uses.
@@ -257,15 +472,46 @@ export const __roomRegistryInternals = {
257
472
  reset(): void {
258
473
  for (const room of rooms.values()) {
259
474
  if (room.pendingTeardown) clearTimeout(room.pendingTeardown);
260
- if (room.interval) clearInterval(room.interval);
475
+ stopHeartbeat(room);
476
+ stopWsPush(room);
477
+ if (room.connectionStatusUnsubscribe) {
478
+ room.connectionStatusUnsubscribe();
479
+ room.connectionStatusUnsubscribe = null;
480
+ }
261
481
  }
262
482
  rooms.clear();
263
483
  },
264
- acquire: acquireRoom,
484
+ acquire(
485
+ baseUrl: string,
486
+ roomId: string,
487
+ userId: string,
488
+ token: string | undefined,
489
+ initialPresence: Record<string, any>,
490
+ heartbeatInterval: number,
491
+ engine: SyncEngine | null = null,
492
+ ): SharedRoom {
493
+ return acquireRoom(
494
+ baseUrl,
495
+ roomId,
496
+ userId,
497
+ token,
498
+ initialPresence,
499
+ heartbeatInterval,
500
+ engine,
501
+ );
502
+ },
265
503
  release: releaseRoom,
266
504
  size(): number {
267
505
  return rooms.size;
268
506
  },
507
+ /** Diagnostic: is this room currently on the WS push path? */
508
+ isWsAttached(room: SharedRoom): boolean {
509
+ return room.wsUnsubscribe !== null;
510
+ },
511
+ /** Diagnostic: is the polling timer running for this room? */
512
+ isPolling(room: SharedRoom): boolean {
513
+ return room.interval !== null;
514
+ },
269
515
  };
270
516
 
271
517
  // ---------------------------------------------------------------------------
@@ -274,12 +520,16 @@ export const __roomRegistryInternals = {
274
520
 
275
521
  /**
276
522
  * Subscribe to a real-time room. Joins on mount, leaves on unmount, and
277
- * polls for peer updates on a configurable interval.
523
+ * receives peer updates over the sync engine's WebSocket push channel
524
+ * (server v0.3.214+). When the WS isn't available (SSE transport, polling
525
+ * transport, OR WS not yet connected) the hook automatically falls back
526
+ * to a 5s `GET /api/rooms/<room>` polling loop — same behaviour as
527
+ * v0.3.213 and earlier.
278
528
  *
279
529
  * Multiple components calling `useRoom` with the same `(roomId, userId,
280
- * baseUrl, token)` tuple share a single underlying join + heartbeat
530
+ * baseUrl, token)` tuple share a single underlying join + subscription
281
531
  * mounting it from 5 components costs one POST /api/rooms/join, one
282
- * heartbeat poll, and one POST /api/rooms/leave.
532
+ * room-subscribe on the wire, and one POST /api/rooms/leave.
283
533
  *
284
534
  * ```tsx
285
535
  * const { peers, isConnected, setPresence, broadcast, leave, error } = useRoom(
@@ -300,6 +550,17 @@ function readStoredToken(): string | undefined {
300
550
  return getReactStorage().get(storageKey('token')) ?? undefined;
301
551
  }
302
552
 
553
+ /** Best-effort engine accessor. SSR / test-harness use-cases that
554
+ * never invoked `init()` end up here with a null engine and the
555
+ * hook silently runs in HTTP-only polling mode. */
556
+ function tryGetSync(): SyncEngine | null {
557
+ try {
558
+ return getSync();
559
+ } catch {
560
+ return null;
561
+ }
562
+ }
563
+
303
564
  export function useRoom(
304
565
  roomId: string,
305
566
  userId: string,
@@ -328,6 +589,7 @@ export function useRoom(
328
589
  const roomRef = useRef<SharedRoom | null>(null);
329
590
 
330
591
  useEffect(() => {
592
+ const engine = tryGetSync();
331
593
  const room = acquireRoom(
332
594
  baseUrl,
333
595
  roomId,
@@ -335,6 +597,7 @@ export function useRoom(
335
597
  token,
336
598
  initialPresence,
337
599
  heartbeatInterval,
600
+ engine,
338
601
  );
339
602
  roomRef.current = room;
340
603