@pylonsync/react 0.3.212 → 0.3.213

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.212",
6
+ "version": "0.3.213",
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.212",
16
- "@pylonsync/sync": "0.3.212"
15
+ "@pylonsync/sdk": "0.3.213",
16
+ "@pylonsync/sync": "0.3.213"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": ">=19.0.0"
@@ -0,0 +1,216 @@
1
+ // Regression coverage for the useRoom refcounted room registry.
2
+ //
3
+ // The chat app mounts useRoom from 3+ components per channel (presence
4
+ // count, presence avatars, composer). Without deduping, that produced N
5
+ // joins + N heartbeats + N leaves per channel view. These tests pin the
6
+ // contract that the module-scoped registry collapses identical
7
+ // (baseUrl, roomId, userId, token) tuples into a single network
8
+ // footprint regardless of how many React subscribers there are.
9
+ //
10
+ // We drive the registry directly via `__roomRegistryInternals` instead
11
+ // of mounting React because the react package doesn't ship a renderer
12
+ // dep. The registry IS the contract under test — the hook is a thin
13
+ // subscriber on top of it.
14
+
15
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
+
17
+ import { __roomRegistryInternals } from "./useRoom";
18
+
19
+ // --- fetch stub --------------------------------------------------------
20
+
21
+ interface RecordedRequest {
22
+ url: string;
23
+ method: string;
24
+ body: any;
25
+ }
26
+
27
+ let recorded: RecordedRequest[];
28
+ let originalFetch: typeof globalThis.fetch;
29
+
30
+ function installFetchStub(): void {
31
+ recorded = [];
32
+ originalFetch = globalThis.fetch;
33
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
34
+ const url = typeof input === "string" ? input : input.toString();
35
+ const method = (init?.method ?? "GET").toUpperCase();
36
+ let body: any = null;
37
+ if (typeof init?.body === "string") {
38
+ try {
39
+ body = JSON.parse(init.body);
40
+ } catch {
41
+ body = init.body;
42
+ }
43
+ }
44
+ recorded.push({ url, method, body });
45
+ // All room endpoints return JSON; the snapshot/members shape is what
46
+ // useRoom unpacks. An empty object is enough to exercise the
47
+ // "joined" success path without inventing peers.
48
+ const responseBody = JSON.stringify({ snapshot: { peers: [] }, members: [] });
49
+ return new Response(responseBody, {
50
+ status: 200,
51
+ headers: { "content-type": "application/json" },
52
+ });
53
+ }) as typeof fetch;
54
+ }
55
+
56
+ function restoreFetch(): void {
57
+ globalThis.fetch = originalFetch;
58
+ }
59
+
60
+ function count(predicate: (r: RecordedRequest) => boolean): number {
61
+ return recorded.filter(predicate).length;
62
+ }
63
+
64
+ function isJoin(r: RecordedRequest): boolean {
65
+ return r.method === "POST" && r.url.endsWith("/api/rooms/join");
66
+ }
67
+ function isLeave(r: RecordedRequest): boolean {
68
+ return r.method === "POST" && r.url.endsWith("/api/rooms/leave");
69
+ }
70
+ function isHeartbeat(r: RecordedRequest): boolean {
71
+ return r.method === "GET" && /\/api\/rooms\/[^/]+$/.test(r.url);
72
+ }
73
+
74
+ // Wait until `pred()` returns true, polling on a microtask cadence. The
75
+ // join + leave paths are promise-chained off pylonFetch, so a couple of
76
+ // microtask flushes is all we need.
77
+ async function flush(): Promise<void> {
78
+ for (let i = 0; i < 5; i++) {
79
+ await Promise.resolve();
80
+ }
81
+ }
82
+
83
+ // --- harness -----------------------------------------------------------
84
+
85
+ const BASE = "http://stub.invalid";
86
+ const ROOM = "room-1";
87
+ const USER = "user-1";
88
+ const TOKEN = "tok-1";
89
+
90
+ beforeEach(() => {
91
+ installFetchStub();
92
+ __roomRegistryInternals.reset();
93
+ });
94
+
95
+ afterEach(() => {
96
+ __roomRegistryInternals.reset();
97
+ restoreFetch();
98
+ });
99
+
100
+ // --- tests -------------------------------------------------------------
101
+
102
+ describe("useRoom shared registry", () => {
103
+ test("three hooks for the same room → 1 join, 1 leave, N (not 3N) heartbeats", async () => {
104
+ // Use a short interval so the test runs fast — semantics are the same.
105
+ const interval = 25;
106
+
107
+ const r1 = __roomRegistryInternals.acquire(BASE, ROOM, USER, TOKEN, {}, interval);
108
+ const r2 = __roomRegistryInternals.acquire(BASE, ROOM, USER, TOKEN, {}, interval);
109
+ const r3 = __roomRegistryInternals.acquire(BASE, ROOM, USER, TOKEN, {}, interval);
110
+
111
+ // All three resolved to the SAME shared room instance.
112
+ expect(r1).toBe(r2);
113
+ expect(r2).toBe(r3);
114
+ expect(__roomRegistryInternals.size()).toBe(1);
115
+
116
+ await flush();
117
+ // Exactly one join across 3 mounts.
118
+ expect(count(isJoin)).toBe(1);
119
+
120
+ // Let the heartbeat fire a few times.
121
+ await new Promise((res) => setTimeout(res, interval * 3 + 10));
122
+ const heartbeatsAfter3Ticks = count(isHeartbeat);
123
+ // 3 hooks × 3 ticks would be 9. Shared registry should land at ~3
124
+ // (one per tick). Allow off-by-one for scheduling jitter.
125
+ expect(heartbeatsAfter3Ticks).toBeGreaterThanOrEqual(2);
126
+ expect(heartbeatsAfter3Ticks).toBeLessThanOrEqual(4);
127
+
128
+ // Release all three. The first two are no-ops (refcount → 2, → 1);
129
+ // the third schedules teardown.
130
+ __roomRegistryInternals.release(r1);
131
+ __roomRegistryInternals.release(r2);
132
+ expect(count(isLeave)).toBe(0); // no leave while refs still > 0
133
+ __roomRegistryInternals.release(r3);
134
+
135
+ // Teardown is deferred via setTimeout(0); wait for it.
136
+ await new Promise((res) => setTimeout(res, 10));
137
+ await flush();
138
+
139
+ expect(count(isLeave)).toBe(1);
140
+ expect(__roomRegistryInternals.size()).toBe(0);
141
+
142
+ // Heartbeats stop after teardown — capture, wait, verify no growth.
143
+ const heartbeatsAtTeardown = count(isHeartbeat);
144
+ await new Promise((res) => setTimeout(res, interval * 2 + 10));
145
+ expect(count(isHeartbeat)).toBe(heartbeatsAtTeardown);
146
+ });
147
+
148
+ test("StrictMode double-mount race: release+acquire in same tick keeps room alive", async () => {
149
+ const interval = 1_000; // long — we don't care about heartbeats here.
150
+ const a = __roomRegistryInternals.acquire(BASE, ROOM, USER, TOKEN, {}, interval);
151
+ await flush();
152
+ expect(count(isJoin)).toBe(1);
153
+
154
+ // StrictMode cleanup: refcount → 0, teardown SCHEDULED but not fired.
155
+ __roomRegistryInternals.release(a);
156
+ // Synchronous remount lands before the setTimeout(0) callback runs.
157
+ const b = __roomRegistryInternals.acquire(BASE, ROOM, USER, TOKEN, {}, interval);
158
+ expect(b).toBe(a); // cancelled teardown — same shared room
159
+
160
+ // Wait long enough for any pending teardown to have fired.
161
+ await new Promise((res) => setTimeout(res, 20));
162
+ await flush();
163
+
164
+ // No leave ever shipped — the room stayed alive across the cycle.
165
+ expect(count(isLeave)).toBe(0);
166
+ expect(count(isJoin)).toBe(1); // and no extra join either
167
+
168
+ __roomRegistryInternals.release(b);
169
+ await new Promise((res) => setTimeout(res, 20));
170
+ await flush();
171
+ expect(count(isLeave)).toBe(1);
172
+ });
173
+
174
+ test("different room ids do NOT dedupe (separate joins, separate leaves)", async () => {
175
+ const a = __roomRegistryInternals.acquire(BASE, "room-A", USER, TOKEN, {}, 1_000);
176
+ const b = __roomRegistryInternals.acquire(BASE, "room-B", USER, TOKEN, {}, 1_000);
177
+ expect(a).not.toBe(b);
178
+ expect(__roomRegistryInternals.size()).toBe(2);
179
+
180
+ await flush();
181
+ expect(count(isJoin)).toBe(2);
182
+
183
+ __roomRegistryInternals.release(a);
184
+ __roomRegistryInternals.release(b);
185
+ await new Promise((res) => setTimeout(res, 20));
186
+ await flush();
187
+ expect(count(isLeave)).toBe(2);
188
+ });
189
+
190
+ test("different tokens do NOT dedupe (auth context differs)", async () => {
191
+ const a = __roomRegistryInternals.acquire(BASE, ROOM, USER, "tok-A", {}, 1_000);
192
+ const b = __roomRegistryInternals.acquire(BASE, ROOM, USER, "tok-B", {}, 1_000);
193
+ expect(a).not.toBe(b);
194
+ expect(__roomRegistryInternals.size()).toBe(2);
195
+
196
+ await flush();
197
+ expect(count(isJoin)).toBe(2);
198
+ });
199
+
200
+ test("acquire+release before join resolves still pairs join with leave once join lands", async () => {
201
+ // Microtask ordering: the join fetch resolves before the setTimeout(0)
202
+ // teardown fires, so even an immediate release waits for the join
203
+ // landing before shipping leave. That's the contract — every join
204
+ // we ship gets paired with a leave (server is idempotent regardless).
205
+ const interval = 1_000;
206
+ const a = __roomRegistryInternals.acquire(BASE, ROOM, USER, TOKEN, {}, interval);
207
+ __roomRegistryInternals.release(a);
208
+
209
+ await new Promise((res) => setTimeout(res, 20));
210
+ await flush();
211
+
212
+ expect(__roomRegistryInternals.size()).toBe(0);
213
+ expect(count(isJoin)).toBe(1);
214
+ expect(count(isLeave)).toBe(1);
215
+ });
216
+ });
package/src/useRoom.ts CHANGED
@@ -45,6 +45,229 @@ export interface UseRoomReturn {
45
45
  error: string | null;
46
46
  }
47
47
 
48
+ // ---------------------------------------------------------------------------
49
+ // Shared room registry — dedupes joins/heartbeats across components
50
+ // ---------------------------------------------------------------------------
51
+ //
52
+ // Apps routinely call `useRoom(roomId, userId)` from 3+ components for the
53
+ // same channel (presence count, presence avatars, typing indicator,
54
+ // composer, …). Without deduping that explodes into N joins on mount, N
55
+ // leaves on unmount, and N×interval heartbeat polls every tick. This
56
+ // registry collapses all calls with the same identity tuple into a single
57
+ // network footprint: one join, one interval, one leave.
58
+ //
59
+ // Identity key includes everything that would change the network shape:
60
+ // baseUrl + token (different server / auth) + roomId + userId.
61
+ //
62
+ // StrictMode safety: when refcount drops to 0, the leave + interval
63
+ // teardown is scheduled on a microtask. If the same hook re-mounts
64
+ // synchronously in the same tick (StrictMode double-mount, fast tab
65
+ // 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.
69
+
70
+ interface SharedRoom {
71
+ /** Number of live React hooks holding this room. */
72
+ refs: number;
73
+ /** Has the join() request resolved with a server response? */
74
+ joined: boolean;
75
+ /** Cached snapshot for late subscribers (mounting after the join landed). */
76
+ peers: RoomPeer[];
77
+ isConnected: boolean;
78
+ error: string | null;
79
+ /** Last presence data the caller pushed via setPresence. */
80
+ presence: Record<string, any>;
81
+ /** Active heartbeat poll. */
82
+ interval: ReturnType<typeof setInterval> | null;
83
+ /** Pending teardown — set when refcount hit 0 but we haven't actually
84
+ * fired leave yet, to absorb StrictMode mount/unmount/mount races. */
85
+ pendingTeardown: ReturnType<typeof setTimeout> | null;
86
+ /** Subscriber callbacks; each useRoom hook registers one. */
87
+ subs: Set<() => void>;
88
+ /** Transport identity (frozen at create time — changing it changes the key). */
89
+ baseUrl: string;
90
+ token: string | undefined;
91
+ roomId: string;
92
+ userId: string;
93
+ heartbeatInterval: number;
94
+ }
95
+
96
+ const rooms = new Map<string, SharedRoom>();
97
+
98
+ function roomKey(
99
+ baseUrl: string,
100
+ roomId: string,
101
+ userId: string,
102
+ token: string | undefined,
103
+ ): string {
104
+ return `${baseUrl}|${roomId}|${userId}|${token ?? ''}`;
105
+ }
106
+
107
+ function notify(room: SharedRoom): void {
108
+ for (const cb of room.subs) cb();
109
+ }
110
+
111
+ function transportFor(room: SharedRoom) {
112
+ return { baseUrl: room.baseUrl, token: room.token };
113
+ }
114
+
115
+ function startHeartbeat(room: SharedRoom): void {
116
+ if (room.interval) return;
117
+ room.interval = setInterval(async () => {
118
+ // Guard against late-firing intervals after teardown — if the room
119
+ // was already evicted from the registry, drop this tick.
120
+ if (!rooms.has(roomKeyFor(room))) return;
121
+ try {
122
+ const body = await pylonFetch<{ members?: RoomPeer[] }>(
123
+ transportFor(room),
124
+ `/api/rooms/${encodeURIComponent(room.roomId)}`,
125
+ );
126
+ if (!rooms.has(roomKeyFor(room))) return;
127
+ const next = (body.members ?? []).filter((p) => p.user_id !== room.userId);
128
+ room.peers = next;
129
+ notify(room);
130
+ } catch {
131
+ // Swallow — next heartbeat will retry. Matches prior behaviour.
132
+ }
133
+ }, room.heartbeatInterval);
134
+ }
135
+
136
+ function roomKeyFor(room: SharedRoom): string {
137
+ return roomKey(room.baseUrl, room.roomId, room.userId, room.token);
138
+ }
139
+
140
+ function joinRoom(room: SharedRoom): void {
141
+ // Fire-and-forget — the join promise resolves into the shared state
142
+ // and notifies all subscribers. Late subscribers either pick up the
143
+ // already-resolved snapshot from `room.peers` or the next heartbeat.
144
+ pylonFetch<{ snapshot?: { peers?: RoomPeer[] } }>(
145
+ transportFor(room),
146
+ '/api/rooms/join',
147
+ {
148
+ method: 'POST',
149
+ json: { room: room.roomId, user_id: room.userId, data: room.presence },
150
+ },
151
+ )
152
+ .then((body) => {
153
+ // The room may have been torn down before join landed (mount →
154
+ // unmount → no remount). In that case the entry is gone from the
155
+ // registry; bail and let the leave path (if it ran) do its thing.
156
+ if (!rooms.has(roomKeyFor(room))) return;
157
+ room.joined = true;
158
+ room.isConnected = true;
159
+ room.error = null;
160
+ if (body.snapshot?.peers) {
161
+ room.peers = body.snapshot.peers.filter((p) => p.user_id !== room.userId);
162
+ }
163
+ notify(room);
164
+ })
165
+ .catch((e: any) => {
166
+ if (!rooms.has(roomKeyFor(room))) return;
167
+ room.error = e?.message ?? 'Failed to join room';
168
+ notify(room);
169
+ });
170
+ }
171
+
172
+ function leaveRoom(room: SharedRoom): void {
173
+ // Only ship leave if join actually landed — matches the original
174
+ // StrictMode race-fix. Server leave is idempotent now, but skipping
175
+ // the call still saves a round trip on every double-mount in dev.
176
+ if (room.joined) {
177
+ pylonFetch(transportFor(room), '/api/rooms/leave', {
178
+ method: 'POST',
179
+ json: { room: room.roomId, user_id: room.userId },
180
+ }).catch(() => {});
181
+ }
182
+ }
183
+
184
+ function acquireRoom(
185
+ baseUrl: string,
186
+ roomId: string,
187
+ userId: string,
188
+ token: string | undefined,
189
+ initialPresence: Record<string, any>,
190
+ heartbeatInterval: number,
191
+ ): SharedRoom {
192
+ const key = roomKey(baseUrl, roomId, userId, token);
193
+ let room = rooms.get(key);
194
+ if (room) {
195
+ // Absorb a pending teardown — caller is about to incref past 0
196
+ // again before the deferred leave fired. Cancel it so we don't
197
+ // tear down a still-live room.
198
+ if (room.pendingTeardown) {
199
+ clearTimeout(room.pendingTeardown);
200
+ room.pendingTeardown = null;
201
+ }
202
+ room.refs += 1;
203
+ return room;
204
+ }
205
+ room = {
206
+ refs: 1,
207
+ joined: false,
208
+ peers: [],
209
+ isConnected: false,
210
+ error: null,
211
+ presence: initialPresence,
212
+ interval: null,
213
+ pendingTeardown: null,
214
+ subs: new Set(),
215
+ baseUrl,
216
+ token,
217
+ roomId,
218
+ userId,
219
+ heartbeatInterval,
220
+ };
221
+ rooms.set(key, room);
222
+ joinRoom(room);
223
+ startHeartbeat(room);
224
+ return room;
225
+ }
226
+
227
+ function releaseRoom(room: SharedRoom): void {
228
+ room.refs -= 1;
229
+ if (room.refs > 0) return;
230
+ // Defer the actual teardown to the next macrotask. React StrictMode
231
+ // unmounts and re-mounts every effect synchronously in dev; without
232
+ // this defer we'd ship join → leave → join on every render. A 0ms
233
+ // setTimeout is enough — the synchronous remount lands first and
234
+ // cancels the pending teardown via `acquireRoom`.
235
+ if (room.pendingTeardown) clearTimeout(room.pendingTeardown);
236
+ room.pendingTeardown = setTimeout(() => {
237
+ // Re-check refcount — a remount might have incref'd inside the
238
+ // same tick after the timer was scheduled.
239
+ if (room.refs > 0) {
240
+ room.pendingTeardown = null;
241
+ return;
242
+ }
243
+ if (room.interval) {
244
+ clearInterval(room.interval);
245
+ room.interval = null;
246
+ }
247
+ rooms.delete(roomKeyFor(room));
248
+ room.pendingTeardown = null;
249
+ leaveRoom(room);
250
+ }, 0);
251
+ }
252
+
253
+ // Exported for tests — lets the regression test reset registry state
254
+ // between cases so leaked rooms from one test can't bleed into the next,
255
+ // and drive the refcount through the same code path the hook uses.
256
+ export const __roomRegistryInternals = {
257
+ reset(): void {
258
+ for (const room of rooms.values()) {
259
+ if (room.pendingTeardown) clearTimeout(room.pendingTeardown);
260
+ if (room.interval) clearInterval(room.interval);
261
+ }
262
+ rooms.clear();
263
+ },
264
+ acquire: acquireRoom,
265
+ release: releaseRoom,
266
+ size(): number {
267
+ return rooms.size;
268
+ },
269
+ };
270
+
48
271
  // ---------------------------------------------------------------------------
49
272
  // Hook
50
273
  // ---------------------------------------------------------------------------
@@ -53,6 +276,11 @@ export interface UseRoomReturn {
53
276
  * Subscribe to a real-time room. Joins on mount, leaves on unmount, and
54
277
  * polls for peer updates on a configurable interval.
55
278
  *
279
+ * Multiple components calling `useRoom` with the same `(roomId, userId,
280
+ * baseUrl, token)` tuple share a single underlying join + heartbeat —
281
+ * mounting it from 5 components costs one POST /api/rooms/join, one
282
+ * heartbeat poll, and one POST /api/rooms/leave.
283
+ *
56
284
  * ```tsx
57
285
  * const { peers, isConnected, setPresence, broadcast, leave, error } = useRoom(
58
286
  * "project-42",
@@ -94,124 +322,97 @@ export function useRoom(
94
322
  const [isConnected, setIsConnected] = useState(false);
95
323
  const [error, setError] = useState<string | null>(null);
96
324
 
97
- const presenceRef = useRef(initialPresence);
98
- const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
325
+ // Remember the most recent shared room this hook is attached to so
326
+ // setPresence / broadcast / leave can route through it without
327
+ // re-running the lifecycle effect on every render.
328
+ const roomRef = useRef<SharedRoom | null>(null);
99
329
 
100
- // Transport config — shared across every room API call. The
101
- // `pylonFetch` helper owns auth + credentials + JSON handling so
102
- // every site here drops the duplicate header builder.
103
- const transport = useCallback(
104
- () => ({ baseUrl, token }),
105
- [baseUrl, token],
106
- );
107
-
108
- // ------- lifecycle: join / heartbeat / leave -------
109
- //
110
- // React StrictMode double-mounts every effect in dev: mount → unmount →
111
- // re-mount. The `joined` ref tracks whether the join() call actually
112
- // landed before the cleanup ran. If join hadn't completed yet, the
113
- // cleanup skips leave entirely — there's nothing to leave on the
114
- // server's side. If join did land, leave fires normally. Server-side
115
- // leave is also idempotent now (200 with was_present:false on
116
- // duplicate), so even a race on this can't error in the network tab.
117
330
  useEffect(() => {
118
- let mounted = true;
119
- let joined = false;
120
-
121
- const join = async () => {
122
- try {
123
- const body = await pylonFetch<{ snapshot?: { peers?: RoomPeer[] } }>(
124
- transport(),
125
- '/api/rooms/join',
126
- {
127
- method: 'POST',
128
- json: { room: roomId, user_id: userId, data: presenceRef.current },
129
- },
130
- );
131
- if (!mounted) return;
132
- joined = true;
133
- setIsConnected(true);
134
- setError(null);
135
- if (body.snapshot?.peers) {
136
- setPeers(
137
- body.snapshot.peers.filter((p) => p.user_id !== userId),
138
- );
139
- }
140
- } catch (e: any) {
141
- if (mounted) setError(e?.message ?? 'Failed to join room');
142
- }
143
- };
331
+ const room = acquireRoom(
332
+ baseUrl,
333
+ roomId,
334
+ userId,
335
+ token,
336
+ initialPresence,
337
+ heartbeatInterval,
338
+ );
339
+ roomRef.current = room;
144
340
 
145
- join();
146
-
147
- // Poll for peer list updates.
148
- intervalRef.current = setInterval(async () => {
149
- if (!mounted) return;
150
- try {
151
- const body = await pylonFetch<{ members?: RoomPeer[] }>(
152
- transport(),
153
- `/api/rooms/${encodeURIComponent(roomId)}`,
154
- );
155
- if (mounted) {
156
- setPeers(
157
- (body.members ?? []).filter((p) => p.user_id !== userId),
158
- );
159
- }
160
- } catch {
161
- // Swallow -- next heartbeat will retry.
162
- }
163
- }, heartbeatInterval);
341
+ // Pull the current shared snapshot immediately so a late subscriber
342
+ // doesn't have to wait one heartbeat to see who's in the room.
343
+ setPeers(room.peers);
344
+ setIsConnected(room.isConnected);
345
+ setError(room.error);
346
+
347
+ const sub = () => {
348
+ setPeers(room.peers);
349
+ setIsConnected(room.isConnected);
350
+ setError(room.error);
351
+ };
352
+ room.subs.add(sub);
164
353
 
165
354
  return () => {
166
- mounted = false;
167
- if (intervalRef.current) clearInterval(intervalRef.current);
168
-
169
- // Skip the leave call when join never completed — fixes the
170
- // StrictMode double-mount race that produced spurious "user
171
- // not in this room" errors. Server leave is also idempotent so
172
- // a stray duplicate would 200 anyway, but we save the round trip.
173
- if (joined) {
174
- pylonFetch(transport(), '/api/rooms/leave', {
175
- method: 'POST',
176
- json: { room: roomId, user_id: userId },
177
- }).catch(() => {});
178
- }
355
+ room.subs.delete(sub);
356
+ if (roomRef.current === room) roomRef.current = null;
357
+ releaseRoom(room);
179
358
  };
180
- // Re-run the entire lifecycle when identity or connection details change.
359
+ // initialPresence is intentionally excluded it's a "set once on
360
+ // first mount" value, and treating an inline-literal default as a
361
+ // dep would thrash the lifecycle on every render.
181
362
  // eslint-disable-next-line react-hooks/exhaustive-deps
182
363
  }, [roomId, userId, baseUrl, token, heartbeatInterval]);
183
364
 
184
365
  // ------- actions -------
366
+ //
367
+ // These read from `roomRef.current` so they always hit the live shared
368
+ // room without needing the effect to re-run when callers change.
185
369
 
186
370
  const setPresence = useCallback(
187
371
  (data: Record<string, any>) => {
188
- presenceRef.current = data;
189
- pylonFetch(transport(), '/api/rooms/presence', {
190
- method: 'POST',
191
- json: { room: roomId, user_id: userId, data },
192
- }).catch(() => {});
372
+ const room = roomRef.current;
373
+ if (room) room.presence = data;
374
+ pylonFetch(
375
+ { baseUrl, token },
376
+ '/api/rooms/presence',
377
+ {
378
+ method: 'POST',
379
+ json: { room: roomId, user_id: userId, data },
380
+ },
381
+ ).catch(() => {});
193
382
  },
194
- [roomId, userId, transport],
383
+ [roomId, userId, baseUrl, token],
195
384
  );
196
385
 
197
386
  const broadcast = useCallback(
198
387
  (topic: string, data: any) => {
199
- pylonFetch(transport(), '/api/rooms/broadcast', {
200
- method: 'POST',
201
- json: { room: roomId, user_id: userId, topic, data },
202
- }).catch(() => {});
388
+ pylonFetch(
389
+ { baseUrl, token },
390
+ '/api/rooms/broadcast',
391
+ {
392
+ method: 'POST',
393
+ json: { room: roomId, user_id: userId, topic, data },
394
+ },
395
+ ).catch(() => {});
203
396
  },
204
- [roomId, userId, transport],
397
+ [roomId, userId, baseUrl, token],
205
398
  );
206
399
 
207
400
  const leave = useCallback(() => {
208
- pylonFetch(transport(), '/api/rooms/leave', {
209
- method: 'POST',
210
- json: { room: roomId, user_id: userId },
211
- }).catch(() => {});
401
+ // Manual leave — bypasses the shared lifecycle and tells the server
402
+ // we're gone immediately. The next effect cleanup will still try to
403
+ // release the refcount; the join-guard means a duplicate leave is
404
+ // skipped if needed, and the server is idempotent anyway.
405
+ pylonFetch(
406
+ { baseUrl, token },
407
+ '/api/rooms/leave',
408
+ {
409
+ method: 'POST',
410
+ json: { room: roomId, user_id: userId },
411
+ },
412
+ ).catch(() => {});
212
413
  setIsConnected(false);
213
414
  setPeers([]);
214
- }, [roomId, userId, transport]);
415
+ }, [roomId, userId, baseUrl, token]);
215
416
 
216
417
  return { peers, isConnected, setPresence, broadcast, leave, error };
217
418
  }
package/tsconfig.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
3
6
  "include": [
4
7
  "src"
5
8
  ]