@pylonsync/react 0.3.211 → 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 +3 -3
- package/src/useRoom.test.ts +216 -0
- package/src/useRoom.ts +295 -94
- package/tsconfig.json +3 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
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.
|
|
16
|
-
"@pylonsync/sync": "0.3.
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
167
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
383
|
+
[roomId, userId, baseUrl, token],
|
|
195
384
|
);
|
|
196
385
|
|
|
197
386
|
const broadcast = useCallback(
|
|
198
387
|
(topic: string, data: any) => {
|
|
199
|
-
pylonFetch(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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,
|
|
397
|
+
[roomId, userId, baseUrl, token],
|
|
205
398
|
);
|
|
206
399
|
|
|
207
400
|
const leave = useCallback(() => {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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,
|
|
415
|
+
}, [roomId, userId, baseUrl, token]);
|
|
215
416
|
|
|
216
417
|
return { peers, isConnected, setPresence, broadcast, leave, error };
|
|
217
418
|
}
|